Add twenty-exa application to internal app ci (#21882)
renamed exa to twenty-exa add twenty-exa to ci check <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21882?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
|
||||
|
||||
const APP_PATH = process.cwd();
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
|
||||
|
||||
function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
const apiUrl = process.env.TWENTY_API_URL;
|
||||
const apiKey = process.env.TWENTY_API_KEY;
|
||||
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
return { apiUrl, apiKey };
|
||||
}
|
||||
|
||||
async function checkServer(apiUrl: string) {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${apiUrl}/healthz`);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Twenty server is not reachable at ${apiUrl}. ` +
|
||||
'Make sure the server is running before executing integration tests.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig(apiUrl: string, apiKey: string) {
|
||||
const payload = JSON.stringify(
|
||||
{
|
||||
remotes: {
|
||||
local: { apiUrl, apiKey, accessToken: apiKey },
|
||||
},
|
||||
defaultRemote: 'local',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
|
||||
}
|
||||
|
||||
export async function setup() {
|
||||
const { apiUrl, apiKey } = validateEnv();
|
||||
|
||||
await checkServer(apiUrl);
|
||||
|
||||
writeConfig(apiUrl, apiKey);
|
||||
|
||||
await appUninstall({ appPath: APP_PATH }).catch(() => {});
|
||||
|
||||
const result = await appDevOnce({
|
||||
appPath: APP_PATH,
|
||||
onProgress: (message: string) => console.log(`[dev] ${message}`),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
console.warn(
|
||||
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application.config';
|
||||
|
||||
describe('App installation', () => {
|
||||
it('should find the installed Exa app in the applications list', async () => {
|
||||
const client = new MetadataApiClient();
|
||||
|
||||
const result = await client.query({
|
||||
findManyApplications: {
|
||||
id: true,
|
||||
name: true,
|
||||
universalIdentifier: true,
|
||||
},
|
||||
});
|
||||
|
||||
const matchingApplication = result.findManyApplications.find(
|
||||
(application: { universalIdentifier: string }) =>
|
||||
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
);
|
||||
|
||||
expect(matchingApplication).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from './roles/default-function.role';
|
||||
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
||||
'2b7f4a2e-9c4b-4a11-b63c-2e5e7d3f5a9a';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Exa',
|
||||
description:
|
||||
'Structured web search powered by Exa. Surfaces entity-aware results (companies, people, research, news) to Twenty AI agents.',
|
||||
logoUrl: 'public/exa-logomark.svg',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
serverVariables: {
|
||||
EXA_API_KEY: {
|
||||
description:
|
||||
'Exa API key. Set by the server admin on this registration after installation; the value is injected into every logic function execution.',
|
||||
isSecret: true,
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type ExaWebSearchInput } from '../types/exa-web-search-input.type';
|
||||
|
||||
const { exaConstructorMock, searchAndContentsMock, chargeCreditsMock } =
|
||||
vi.hoisted(() => ({
|
||||
exaConstructorMock: vi.fn(),
|
||||
searchAndContentsMock: vi.fn(),
|
||||
chargeCreditsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('exa-js', () => ({
|
||||
default: vi.fn(function (apiKey: string) {
|
||||
exaConstructorMock(apiKey);
|
||||
|
||||
return { searchAndContents: searchAndContentsMock };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('twenty-sdk/billing', () => ({
|
||||
chargeCredits: chargeCreditsMock,
|
||||
}));
|
||||
|
||||
import exaWebSearch from '../exa-web-search';
|
||||
|
||||
type ExaWebSearchResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
result?: { title: string; url: string; snippet: string }[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const handler = exaWebSearch.config.handler as (
|
||||
parameters: ExaWebSearchInput,
|
||||
) => Promise<ExaWebSearchResult>;
|
||||
|
||||
const API_KEY = 'exa-test-key';
|
||||
|
||||
describe('exa_web_search handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.EXA_API_KEY = API_KEY;
|
||||
chargeCreditsMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return a configuration error and skip the search when EXA_API_KEY is not set', async () => {
|
||||
delete process.env.EXA_API_KEY;
|
||||
|
||||
const result = await handler({ query: 'twenty crm' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Exa is not configured');
|
||||
expect(result.error).toContain('EXA_API_KEY is not set');
|
||||
expect(exaConstructorMock).not.toHaveBeenCalled();
|
||||
expect(searchAndContentsMock).not.toHaveBeenCalled();
|
||||
expect(chargeCreditsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should map Exa results to title/url/snippet and report success', async () => {
|
||||
searchAndContentsMock.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
title: 'Twenty CRM',
|
||||
url: 'https://twenty.com',
|
||||
highlights: ['Open-source CRM', 'Built with modern tech'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await handler({ query: 'twenty crm' });
|
||||
|
||||
expect(exaConstructorMock).toHaveBeenCalledWith(API_KEY);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: 'Found 1 results for "twenty crm"',
|
||||
result: [
|
||||
{
|
||||
title: 'Twenty CRM',
|
||||
url: 'https://twenty.com',
|
||||
snippet: 'Open-source CRM\nBuilt with modern tech',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should default to DEFAULT_NUM_RESULTS and search with type "auto" and highlights when numResults is omitted', async () => {
|
||||
searchAndContentsMock.mockResolvedValue({ results: [] });
|
||||
|
||||
await handler({ query: 'acme corp', category: 'company' });
|
||||
|
||||
expect(searchAndContentsMock).toHaveBeenCalledWith('acme corp', {
|
||||
type: 'auto',
|
||||
numResults: 10,
|
||||
category: 'company',
|
||||
highlights: { numSentences: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward an explicit numResults to Exa', async () => {
|
||||
searchAndContentsMock.mockResolvedValue({ results: [] });
|
||||
|
||||
await handler({ query: 'acme corp', numResults: 3 });
|
||||
|
||||
expect(searchAndContentsMock).toHaveBeenCalledWith(
|
||||
'acme corp',
|
||||
expect.objectContaining({ numResults: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include the category in the success message when one is provided', async () => {
|
||||
searchAndContentsMock.mockResolvedValue({
|
||||
results: [{ title: 'A', url: 'https://a.com', highlights: ['x'] }],
|
||||
});
|
||||
|
||||
const result = await handler({ query: 'openai', category: 'company' });
|
||||
|
||||
expect(result.message).toBe('Found 1 results for "openai" (category: company)');
|
||||
});
|
||||
|
||||
it('should fall back to empty strings for a missing title and missing highlights', async () => {
|
||||
searchAndContentsMock.mockResolvedValue({
|
||||
results: [{ url: 'https://no-title.com' }],
|
||||
});
|
||||
|
||||
const result = await handler({ query: 'edge case' });
|
||||
|
||||
expect(result.result).toEqual([
|
||||
{ title: '', url: 'https://no-title.com', snippet: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should charge the Exa base price (7000 micro-credits) for up to DEFAULT_NUM_RESULTS results', async () => {
|
||||
searchAndContentsMock.mockResolvedValue({
|
||||
results: [{ title: 'A', url: 'https://a.com', highlights: ['x'] }],
|
||||
});
|
||||
|
||||
await handler({ query: 'pricing base' });
|
||||
|
||||
expect(chargeCreditsMock).toHaveBeenCalledWith({
|
||||
creditsUsedMicro: 7000,
|
||||
operationType: 'WEB_SEARCH',
|
||||
resourceContext: 'exa',
|
||||
});
|
||||
});
|
||||
|
||||
it('should add 1000 micro-credits per result beyond DEFAULT_NUM_RESULTS', async () => {
|
||||
searchAndContentsMock.mockResolvedValue({
|
||||
results: Array.from({ length: 12 }, (_, index) => ({
|
||||
title: `Result ${index}`,
|
||||
url: `https://example.com/${index}`,
|
||||
highlights: ['snippet'],
|
||||
})),
|
||||
});
|
||||
|
||||
await handler({ query: 'pricing extra', numResults: 12 });
|
||||
|
||||
expect(chargeCreditsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ creditsUsedMicro: 9000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a failure result and not charge credits when the Exa search throws', async () => {
|
||||
searchAndContentsMock.mockRejectedValue(new Error('Exa rate limit exceeded'));
|
||||
|
||||
const result = await handler({ query: 'boom' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'Web search failed for "boom"',
|
||||
error: 'Exa rate limit exceeded',
|
||||
});
|
||||
expect(chargeCreditsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should time out and fail when Exa does not respond within the inner timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
searchAndContentsMock.mockReturnValue(new Promise<never>(() => {}));
|
||||
|
||||
const resultPromise = handler({ query: 'slow query' });
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'Web search failed for "slow query"',
|
||||
error: 'Exa search timed out',
|
||||
});
|
||||
expect(chargeCreditsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_NUM_RESULTS = 10;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// Mirrors exa-js's `BaseSearchOptions['category']` union. Kept as a
|
||||
// runtime list so the tool's JSON-Schema `enum` can reference it.
|
||||
export const EXA_CATEGORIES = [
|
||||
'company',
|
||||
'research paper',
|
||||
'news',
|
||||
'pdf',
|
||||
'personal site',
|
||||
'financial report',
|
||||
'people',
|
||||
] as const;
|
||||
@@ -0,0 +1,118 @@
|
||||
import Exa from 'exa-js';
|
||||
import { chargeCredits } from 'twenty-sdk/billing';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import { DEFAULT_NUM_RESULTS } from './constants/default-num-results.constant';
|
||||
import { exaWebSearchInputSchema } from './schemas/exa-web-search-input.schema';
|
||||
import { type ExaWebSearchInput } from './types/exa-web-search-input.type';
|
||||
|
||||
// Number of sentences surfaced per result — keeps the snippet compact
|
||||
// enough for an LLM to read many results without blowing the context.
|
||||
const HIGHLIGHT_NUM_SENTENCES = 5;
|
||||
|
||||
// Inner bound — the runtime's `timeoutSeconds: 30` is the outer kill
|
||||
// switch; this one ensures we return a clean error on a slow Exa response.
|
||||
const EXA_SEARCH_TIMEOUT_MS = 25_000;
|
||||
|
||||
// Exa auto-search pricing (2025): $0.007 covers the first 10 results,
|
||||
// $0.001 per additional result. Twenty charges in micro-credits where
|
||||
// 1 USD = 1_000_000 micro-credits (DOLLAR_TO_CREDIT_MULTIPLIER).
|
||||
const MICRO_CREDITS_PER_DOLLAR = 1_000_000;
|
||||
const EXA_BASE_COST_DOLLARS = 0.007;
|
||||
const EXA_COST_PER_ADDITIONAL_RESULT_DOLLARS = 0.001;
|
||||
|
||||
type ExaSearchResult = {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
};
|
||||
|
||||
type HandlerResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
result?: ExaSearchResult[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const computeMicroCredits = (numResults: number): number => {
|
||||
const additional = Math.max(0, numResults - DEFAULT_NUM_RESULTS);
|
||||
const dollars =
|
||||
EXA_BASE_COST_DOLLARS + additional * EXA_COST_PER_ADDITIONAL_RESULT_DOLLARS;
|
||||
|
||||
return Math.round(dollars * MICRO_CREDITS_PER_DOLLAR);
|
||||
};
|
||||
|
||||
const handler = async (
|
||||
parameters: ExaWebSearchInput,
|
||||
): Promise<HandlerResult> => {
|
||||
const apiKey = process.env.EXA_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Exa is not configured',
|
||||
error:
|
||||
'EXA_API_KEY is not set. The server admin must provide an Exa API key for this tool to work.',
|
||||
};
|
||||
}
|
||||
|
||||
const query = parameters.query;
|
||||
const numResults = parameters.numResults ?? DEFAULT_NUM_RESULTS;
|
||||
const category = parameters.category;
|
||||
|
||||
try {
|
||||
const exa = new Exa(apiKey);
|
||||
|
||||
// exa-js has no built-in abort — race it manually.
|
||||
const response = await Promise.race([
|
||||
exa.searchAndContents(query, {
|
||||
type: 'auto',
|
||||
numResults,
|
||||
category,
|
||||
highlights: { numSentences: HIGHLIGHT_NUM_SENTENCES },
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Exa search timed out')),
|
||||
EXA_SEARCH_TIMEOUT_MS,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const results: ExaSearchResult[] = response.results.map((result) => ({
|
||||
title: result.title ?? '',
|
||||
url: result.url,
|
||||
snippet: result.highlights?.join('\n') ?? '',
|
||||
}));
|
||||
|
||||
await chargeCredits({
|
||||
creditsUsedMicro: computeMicroCredits(results.length),
|
||||
operationType: 'WEB_SEARCH',
|
||||
resourceContext: 'exa',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ${results.length} results for "${query}"${category ? ` (category: ${category})` : ''}`,
|
||||
result: results,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Web search failed for "${query}"`,
|
||||
error: error instanceof Error ? error.message : 'Web search failed',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: '4c6f9b2a-5d8e-4c2a-af18-3e0b9c6a7e4f',
|
||||
name: 'exa_web_search',
|
||||
description:
|
||||
'Structured web search powered by Exa. Returns entity-aware results with category filtering (companies, people, research papers, news, and other content types). Prefer this when the query benefits from structured data or a specific category. For general real-time web browsing, prefer the native `web_search` tool when it is available.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: exaWebSearchInputSchema,
|
||||
},
|
||||
handler,
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
import { DEFAULT_NUM_RESULTS } from '../constants/default-num-results.constant';
|
||||
import { EXA_CATEGORIES } from '../constants/exa-categories.constant';
|
||||
|
||||
const MAX_NUM_RESULTS = 30;
|
||||
|
||||
export const exaWebSearchInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The search query to look up on the web. Be specific and include relevant keywords for better results.',
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: [...EXA_CATEGORIES],
|
||||
description:
|
||||
'Optional content category to focus the search. Use "company" for business/organization info, "people" for person profiles, "news" for recent articles, "research paper" for academic content.',
|
||||
},
|
||||
numResults: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: MAX_NUM_RESULTS,
|
||||
description: `Number of search results to return. Defaults to ${DEFAULT_NUM_RESULTS}, max ${MAX_NUM_RESULTS}. Use more results when you need comprehensive coverage.`,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type EXA_CATEGORIES } from '../constants/exa-categories.constant';
|
||||
|
||||
export type ExaWebSearchInput = {
|
||||
query: string;
|
||||
category?: (typeof EXA_CATEGORIES)[number];
|
||||
numResults?: number;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineRole } from 'twenty-sdk/define';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'9a55f3d2-f87c-4f1b-a0f3-5d1c6b8a2e4c';
|
||||
|
||||
// Exa's logic function never reads workspace data — it only reads
|
||||
// EXA_API_KEY and calls Exa's external API — so the role needs no object
|
||||
// permissions. Kept explicit so the manifest records the "zero data
|
||||
// access" posture.
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Exa function role',
|
||||
description: 'No-op role for the exa_web_search logic function',
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
canUpdateAllSettings: false,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToUsers: false,
|
||||
canBeAssignedToApiKeys: false,
|
||||
objectPermissions: [],
|
||||
fieldPermissions: [],
|
||||
});
|
||||
Reference in New Issue
Block a user