feat: improve AI chat - system prompt, tool output, context window display (#17769)

⚠️ **AI-generated PR — not ready for review** ⚠️

cc @FelixMalfait

---

## Changes

### System prompt improvements
- Explicit skill-before-tools workflow to prevent the model from calling
tools without loading the matching skill first
- Data efficiency guidance (default small limits, use filters)
- Pluralized `load_skill` → `load_skills` for consistency with
`load_tools`

### Token usage reduction
- Output serialization layer: strips null/undefined/empty values from
tool results
- Lowered default `find_*` limit from 100 → 10, max from 1000 → 100

### System object tool generation
- System objects (calendar events, messages, etc.) now generate AI tools
- Only workflow-related and favorite-related objects are excluded

### Context window display fix
- **Bug**: UI compared cumulative tokens (sum of all turns) against
single-request context window → showed 100% after a few turns
- **Fix**: Track `conversationSize` (last step's `inputTokens`) which
represents the actual conversation history size sent to the model
- New `conversationSize` column on thread entity with migration

### Workspace AI instructions
- Support for custom workspace-level AI instructions

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-09 14:26:02 +01:00
committed by GitHub
co-authored by claude[bot] <41898282+claude[bot]@users.noreply.github.com>
parent 6c7c389785
commit 3216b634a3
105 changed files with 3245 additions and 1714 deletions
@@ -1,94 +0,0 @@
import { type ToolSet } from 'ai';
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
import {
createAndConfigureStep,
type WorkflowStepToolsDeps,
} from './step-builder.utils';
export function buildCreateRecordStepTool(
deps: WorkflowStepToolsDeps,
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields: RestrictedFieldsPermissions,
context: ToolGeneratorContext,
): ToolSet {
const recordPropertiesSchema = generateRecordPropertiesZodSchema(
objectMetadata,
false,
restrictedFields,
);
const inputSchema = z.object({
workflowVersionId: z
.string()
.describe('The ID of the workflow version to add the step to'),
parentStepId: z
.string()
.optional()
.describe('Optional ID of the parent step this step should come after'),
stepName: z
.string()
.optional()
.describe(
`Name for this step (default: "Create ${objectMetadata.labelSingular}")`,
),
input: recordPropertiesSchema.describe(
`The ${objectMetadata.labelSingular} record data. Use {{trigger.fieldName}} or {{stepId.fieldName}} syntax to reference dynamic values from previous steps.`,
),
});
return {
[`configure_create_${objectMetadata.nameSingular}_step`]: {
description:
`Add a workflow step that creates a ${objectMetadata.labelSingular} record. ` +
`Provide the record fields directly - use {{trigger.fieldName}} or {{stepId.fieldName}} to reference values from previous steps.`,
inputSchema,
execute: async (parameters: z.infer<typeof inputSchema>) => {
try {
const { stepId, result } = await createAndConfigureStep(
deps,
context.workspaceId,
parameters.workflowVersionId,
WorkflowActionType.CREATE_RECORD,
parameters.parentStepId,
{
name:
parameters.stepName || `Create ${objectMetadata.labelSingular}`,
type: WorkflowActionType.CREATE_RECORD,
valid: true,
settings: {
input: {
objectName: objectMetadata.nameSingular,
objectRecord: parameters.input,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
},
);
return {
success: true,
message: `Created workflow step to create ${objectMetadata.labelSingular}`,
result: { stepId, step: result },
};
} catch (error) {
return {
success: false,
error: error.message,
message: `Failed to create ${objectMetadata.labelSingular} workflow step: ${error.message}`,
};
}
},
},
};
}
@@ -1,87 +0,0 @@
import { type ToolSet } from 'ai';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
import {
createAndConfigureStep,
type WorkflowStepToolsDeps,
} from './step-builder.utils';
export function buildDeleteRecordStepTool(
deps: WorkflowStepToolsDeps,
objectMetadata: ObjectMetadataForToolSchema,
context: ToolGeneratorContext,
): ToolSet {
const inputSchema = z.object({
workflowVersionId: z
.string()
.describe('The ID of the workflow version to add the step to'),
parentStepId: z
.string()
.optional()
.describe('Optional ID of the parent step this step should come after'),
stepName: z
.string()
.optional()
.describe(
`Name for this step (default: "Delete ${objectMetadata.labelSingular}")`,
),
objectRecordId: z
.string()
.describe(
`The ID of the ${objectMetadata.labelSingular} record to delete. Use {{trigger.id}} or {{stepId.result.id}} to reference a dynamic ID.`,
),
});
return {
[`configure_delete_${objectMetadata.nameSingular}_step`]: {
description:
`Add a workflow step that deletes a ${objectMetadata.labelSingular} record. ` +
`This performs a soft delete (marks as deleted but preserves data).`,
inputSchema,
execute: async (parameters: z.infer<typeof inputSchema>) => {
try {
const { stepId, result } = await createAndConfigureStep(
deps,
context.workspaceId,
parameters.workflowVersionId,
WorkflowActionType.DELETE_RECORD,
parameters.parentStepId,
{
name:
parameters.stepName || `Delete ${objectMetadata.labelSingular}`,
type: WorkflowActionType.DELETE_RECORD,
valid: true,
settings: {
input: {
objectName: objectMetadata.nameSingular,
objectRecordId: parameters.objectRecordId,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
},
);
return {
success: true,
message: `Created workflow step to delete ${objectMetadata.labelSingular}`,
result: { stepId, step: result },
};
} catch (error) {
return {
success: false,
error: error.message,
message: `Failed to create ${objectMetadata.labelSingular} delete workflow step: ${error.message}`,
};
}
},
},
};
}
@@ -1,149 +0,0 @@
import { type ToolSet } from 'ai';
import {
FieldMetadataType,
RelationType,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
import { ObjectRecordOrderBySchema } from 'src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema';
import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
import {
createAndConfigureStep,
type WorkflowStepToolsDeps,
} from './step-builder.utils';
export function buildFindRecordsStepTool(
deps: WorkflowStepToolsDeps,
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields: RestrictedFieldsPermissions,
context: ToolGeneratorContext,
): ToolSet {
const filterShape: Record<string, z.ZodTypeAny> = {};
objectMetadata.fields.forEach((field) => {
if (shouldExcludeFieldFromAgentToolSchema(field)) {
return;
}
if (restrictedFields?.[field.id]?.canRead === false) {
return;
}
const filterSchema = generateFieldFilterZodSchema(field);
if (!filterSchema) {
return;
}
const isManyToOneRelationField =
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE;
filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] =
filterSchema;
});
const inputSchema = z.object({
workflowVersionId: z
.string()
.describe('The ID of the workflow version to add the step to'),
parentStepId: z
.string()
.optional()
.describe('Optional ID of the parent step this step should come after'),
stepName: z
.string()
.optional()
.describe(
`Name for this step (default: "Find ${objectMetadata.labelPlural}")`,
),
limit: z
.number()
.int()
.positive()
.max(1000)
.optional()
.default(100)
.describe('Maximum number of records to return (default: 100)'),
orderBy: ObjectRecordOrderBySchema.optional().describe(
'Sort records by field(s). Each item is an object with field name as key, sort direction as value.',
),
filter: z
.object(filterShape)
.partial()
.optional()
.describe(
`Filter criteria for ${objectMetadata.labelPlural}. Use {{trigger.fieldName}} or {{stepId.fieldName}} to reference dynamic values.`,
),
});
return {
[`configure_find_${objectMetadata.namePlural}_step`]: {
description:
`Add a workflow step that searches for ${objectMetadata.labelPlural} records. ` +
`Results can be used in subsequent steps via {{stepId.result}}.`,
inputSchema,
execute: async (parameters: z.infer<typeof inputSchema>) => {
try {
const filterConfig = parameters.filter
? {
recordFilters: Object.entries(parameters.filter).map(
([fieldName, filterValue]) => ({
fieldName,
filter: filterValue,
}),
),
}
: undefined;
const { stepId, result } = await createAndConfigureStep(
deps,
context.workspaceId,
parameters.workflowVersionId,
WorkflowActionType.FIND_RECORDS,
parameters.parentStepId,
{
name: parameters.stepName || `Find ${objectMetadata.labelPlural}`,
type: WorkflowActionType.FIND_RECORDS,
valid: true,
settings: {
input: {
objectName: objectMetadata.nameSingular,
limit: parameters.limit,
filter: filterConfig,
orderBy: parameters.orderBy
? { gqlOperationOrderBy: parameters.orderBy }
: undefined,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
},
);
return {
success: true,
message: `Created workflow step to find ${objectMetadata.labelPlural}`,
result: { stepId, step: result },
};
} catch (error) {
return {
success: false,
error: error.message,
message: `Failed to create ${objectMetadata.labelPlural} find workflow step: ${error.message}`,
};
}
},
},
};
}
@@ -1,52 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { type WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
export type WorkflowStepToolsDeps = {
workflowVersionStepService: {
createWorkflowVersionStep: (args: {
workspaceId: string;
input: {
workflowVersionId: string;
stepType: WorkflowActionType;
parentStepId?: string;
id?: string;
};
}) => Promise<unknown>;
updateWorkflowVersionStep: (args: {
workspaceId: string;
workflowVersionId: string;
step: unknown;
}) => Promise<unknown>;
};
};
export async function createAndConfigureStep(
deps: WorkflowStepToolsDeps,
workspaceId: string,
workflowVersionId: string,
stepType: WorkflowActionType,
parentStepId: string | undefined,
stepConfig: object,
) {
const stepId = uuidv4();
await deps.workflowVersionStepService.createWorkflowVersionStep({
workspaceId,
input: {
workflowVersionId,
stepType,
parentStepId,
id: stepId,
},
});
const result =
await deps.workflowVersionStepService.updateWorkflowVersionStep({
workspaceId,
workflowVersionId,
step: { id: stepId, ...stepConfig },
});
return { stepId, result };
}
@@ -1,101 +0,0 @@
import { type ToolSet } from 'ai';
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
import {
createAndConfigureStep,
type WorkflowStepToolsDeps,
} from './step-builder.utils';
export function buildUpdateRecordStepTool(
deps: WorkflowStepToolsDeps,
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields: RestrictedFieldsPermissions,
context: ToolGeneratorContext,
): ToolSet {
const recordPropertiesSchema = generateRecordPropertiesZodSchema(
objectMetadata,
false,
restrictedFields,
);
const updateSchema = recordPropertiesSchema.partial();
const inputSchema = z.object({
workflowVersionId: z
.string()
.describe('The ID of the workflow version to add the step to'),
parentStepId: z
.string()
.optional()
.describe('Optional ID of the parent step this step should come after'),
stepName: z
.string()
.optional()
.describe(
`Name for this step (default: "Update ${objectMetadata.labelSingular}")`,
),
objectRecordId: z
.string()
.describe(
`The ID of the ${objectMetadata.labelSingular} record to update. Use {{trigger.id}} or {{stepId.result.id}} to reference a dynamic ID.`,
),
fieldsToUpdate: updateSchema.describe(
`The fields to update on the ${objectMetadata.labelSingular} record. Only include fields you want to change. Use {{trigger.fieldName}} or {{stepId.fieldName}} to reference dynamic values.`,
),
});
return {
[`configure_update_${objectMetadata.nameSingular}_step`]: {
description:
`Add a workflow step that updates an existing ${objectMetadata.labelSingular} record. ` +
`Specify the record ID and only the fields you want to update.`,
inputSchema,
execute: async (parameters: z.infer<typeof inputSchema>) => {
try {
const { stepId, result } = await createAndConfigureStep(
deps,
context.workspaceId,
parameters.workflowVersionId,
WorkflowActionType.UPDATE_RECORD,
parameters.parentStepId,
{
name:
parameters.stepName || `Update ${objectMetadata.labelSingular}`,
type: WorkflowActionType.UPDATE_RECORD,
valid: true,
settings: {
input: {
objectName: objectMetadata.nameSingular,
objectRecordId: parameters.objectRecordId,
objectRecord: parameters.fieldsToUpdate,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
},
);
return {
success: true,
message: `Created workflow step to update ${objectMetadata.labelSingular}`,
result: { stepId, step: result },
};
} catch (error) {
return {
success: false,
error: error.message,
message: `Failed to create ${objectMetadata.labelSingular} update workflow step: ${error.message}`,
};
}
},
},
};
}
@@ -1,75 +0,0 @@
import { type ToolSet } from 'ai';
import {
type ObjectWithPermission,
type ToolGeneratorContext,
} from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { buildCreateRecordStepTool } from './builders/create-record-step.builder';
import { buildDeleteRecordStepTool } from './builders/delete-record-step.builder';
import { buildFindRecordsStepTool } from './builders/find-records-step.builder';
import { type WorkflowStepToolsDeps } from './builders/step-builder.utils';
import { buildUpdateRecordStepTool } from './builders/update-record-step.builder';
export { type WorkflowStepToolsDeps } from './builders/step-builder.utils';
export const createWorkflowStepToolsFactory = (deps: WorkflowStepToolsDeps) => {
return (
{
objectMetadata,
restrictedFields,
canCreate,
canRead,
canUpdate,
canDelete,
}: ObjectWithPermission,
context: ToolGeneratorContext,
): ToolSet => {
const tools: ToolSet = {};
if (canCreate) {
Object.assign(
tools,
buildCreateRecordStepTool(
deps,
objectMetadata,
restrictedFields,
context,
),
);
}
if (canUpdate) {
Object.assign(
tools,
buildUpdateRecordStepTool(
deps,
objectMetadata,
restrictedFields,
context,
),
);
}
if (canRead) {
Object.assign(
tools,
buildFindRecordsStepTool(
deps,
objectMetadata,
restrictedFields,
context,
),
);
}
if (canDelete) {
Object.assign(
tools,
buildDeleteRecordStepTool(deps, objectMetadata, context),
);
}
return tools;
};
};
@@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
@@ -11,10 +10,6 @@ import { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workfl
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service';
import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service';
import {
createWorkflowStepToolsFactory,
type WorkflowStepToolsDeps,
} from 'src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory';
import { createActivateWorkflowVersionTool } from 'src/modules/workflow/workflow-tools/tools/activate-workflow-version.tool';
import { createComputeStepOutputSchemaTool } from 'src/modules/workflow/workflow-tools/tools/compute-step-output-schema.tool';
import { createCreateCompleteWorkflowTool } from 'src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool';
@@ -34,7 +29,6 @@ import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-t
@Injectable()
export class WorkflowToolWorkspaceService {
private readonly deps: WorkflowToolDependencies;
private readonly workflowStepToolsDeps: WorkflowStepToolsDeps;
constructor(
workflowVersionStepService: WorkflowVersionStepWorkspaceService,
@@ -45,7 +39,6 @@ export class WorkflowToolWorkspaceService {
workflowSchemaService: WorkflowSchemaWorkspaceService,
globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
recordPositionService: RecordPositionService,
private readonly perObjectToolGenerator: PerObjectToolGeneratorService,
) {
this.deps = {
workflowVersionStepService,
@@ -57,10 +50,6 @@ export class WorkflowToolWorkspaceService {
globalWorkspaceOrmManager,
recordPositionService,
};
this.workflowStepToolsDeps = {
workflowVersionStepService,
};
}
// Generates static workflow tools that don't depend on workspace objects
@@ -136,22 +125,4 @@ export class WorkflowToolWorkspaceService {
[getWorkflowCurrentVersion.name]: getWorkflowCurrentVersion,
};
}
// Generates dynamic step configurator tools for each workspace object
async generateRecordStepConfiguratorTools(
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
): Promise<ToolSet> {
const workflowStepToolsFactory = createWorkflowStepToolsFactory(
this.workflowStepToolsDeps,
);
return this.perObjectToolGenerator.generate(
{
workspaceId,
rolePermissionConfig,
},
[workflowStepToolsFactory],
);
}
}
@@ -1,7 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { ToolGeneratorModule } from 'src/engine/core-modules/tool-generator/tool-generator.module';
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module';
import { WorkflowVersionEdgeModule } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.module';
@@ -22,7 +21,6 @@ import { WorkflowToolWorkspaceService } from './services/workflow-tool.workspace
WorkflowTriggerModule,
WorkflowSchemaModule,
RecordPositionModule,
ToolGeneratorModule,
],
providers: [
WorkflowToolWorkspaceService,