Files
twenty/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts
T
Félix MalfaitandClaude Opus 4.7 f76cd04f7f feat(app): migrate Exa web search to a pre-installed app
Exa moves from a hardcoded action tool to a standalone app at
`packages/twenty-apps/community/exa`, installable via the
PRE_INSTALLED_APPS infrastructure landed in the prior PR. The tool
surfaces to the model as `app_exa_web_search` (logic-function-sourced,
hence the `app_` prefix).

New app package:

- `application.config.ts` declares the Exa app with one server variable
  (EXA_API_KEY, required, secret). Server admins set EXA_API_KEY as an
  env var; PreInstalledAppsService seeds it into the app registration
  at bootstrap.
- `logic-functions/exa-web-search.ts` is the runtime handler. Reads
  EXA_API_KEY from its injected execution env, calls Exa via the
  official SDK, records usage by POSTing to the generic
  /app/billing/charge endpoint (with the injected
  TWENTY_APP_ACCESS_TOKEN), returns the structured results.
- Tool input schema mirrors the previous WebSearchTool: query,
  optional category, optional numResults (1-30).

Removed (now provided by the app):

- packages/twenty-server/src/engine/core-modules/web-search/ —
  the entire module, drivers, types, and interface
- packages/twenty-server/src/engine/core-modules/tool/tools/
  web-search-tool/ — WebSearchTool, its schema, and input type
- WebSearchTool injection + toolMap entry + descriptor in
  ActionToolProvider
- WebSearchService injection from ActionToolProvider
- WebSearchModule from CoreEngineModule imports
- WEB_SEARCH_DRIVER config variable (no longer needed — Exa is an app)
- Custom driver-toggle wiring in chat preload: replaced with plain
  `app_exa_web_search` preload

EXA_API_KEY config variable stays but its description now says it
seeds the Exa app's server variables. Chat and the frontend display
already use the new `app_exa_web_search` name.

Deployment coordination: server admins must publish @twenty-apps/exa
to the app registry and set PRE_INSTALLED_APPS=@twenty-apps/exa (plus
EXA_API_KEY) post-merge. Existing workspaces backfill via the
`install-pre-installed-apps` CLI command added in the prior PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:40:24 +02:00

135 lines
3.6 KiB
TypeScript

import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { z } from 'zod';
import { type ToolInput } from '@/ai/types/ToolInput';
import { isDefined } from 'twenty-shared/utils';
const DirectQuerySchema = z.object({ query: z.string() });
const NestedQuerySchema = z.object({
action: z.object({ query: z.string() }),
});
const CustomLoadingMessageSchema = z.object({ loadingMessage: z.string() });
const ExecuteToolSchema = z.object({
toolName: z.coerce.string(),
arguments: z.unknown(),
});
const LearnToolsSchema = z.object({ toolNames: z.array(z.string()) });
const LoadSkillsSchema = z.object({ skillNames: z.array(z.string()) });
const extractSearchQuery = (input: ToolInput): string => {
const direct = DirectQuerySchema.safeParse(input);
if (direct.success) {
return direct.data.query;
}
const nested = NestedQuerySchema.safeParse(input);
if (nested.success) {
return nested.data.action.query;
}
return '';
};
const extractCustomLoadingMessage = (input: ToolInput): string | null => {
const parsed = CustomLoadingMessageSchema.safeParse(input);
return parsed.success ? parsed.data.loadingMessage : null;
};
export const resolveToolInput = (
input: ToolInput,
toolName: string,
): { resolvedInput: ToolInput; resolvedToolName: string } => {
if (toolName !== 'execute_tool') {
return { resolvedInput: input, resolvedToolName: toolName };
}
const parsed = ExecuteToolSchema.safeParse(input);
if (!parsed.success) {
return { resolvedInput: input, resolvedToolName: toolName };
}
return {
resolvedInput: parsed.data.arguments as ToolInput,
resolvedToolName: parsed.data.toolName,
};
};
const extractLearnToolNames = (input: ToolInput): string => {
const parsed = LearnToolsSchema.safeParse(input);
return parsed.success ? parsed.data.toolNames.join(', ') : '';
};
const extractSkillNames = (input: ToolInput): string => {
const parsed = LoadSkillsSchema.safeParse(input);
return parsed.success ? parsed.data.skillNames.join(', ') : '';
};
const formatToolName = (toolName: string): string => {
return toolName.replace(/_/g, ' ');
};
export const getToolDisplayMessage = (
input: ToolInput,
toolName: string,
isFinished?: boolean,
): string => {
const { resolvedInput, resolvedToolName } = resolveToolInput(input, toolName);
const byStatus = (finished: string, inProgress: string): string =>
isFinished ? finished : inProgress;
if (
resolvedToolName === 'web_search' ||
resolvedToolName === 'app_exa_web_search'
) {
const query = extractSearchQuery(resolvedInput);
if (isNonEmptyString(query)) {
return byStatus(
t`Searched the web for ${query}`,
t`Searching the web for ${query}`,
);
}
return byStatus(t`Searched the web`, t`Searching the web`);
}
if (resolvedToolName === 'learn_tools') {
const names = extractLearnToolNames(resolvedInput);
if (isNonEmptyString(names)) {
return byStatus(t`Learned ${names}`, t`Learning ${names}`);
}
return byStatus(t`Learned tools`, t`Learning tools...`);
}
if (resolvedToolName === 'load_skills') {
const names = extractSkillNames(resolvedInput);
if (isNonEmptyString(names)) {
return byStatus(t`Loaded ${names}`, t`Loading ${names}`);
}
return byStatus(t`Loaded skills`, t`Loading skills...`);
}
const customMessage = extractCustomLoadingMessage(resolvedInput);
if (isDefined(customMessage)) {
return customMessage;
}
const formattedName = formatToolName(resolvedToolName);
return byStatus(t`Ran ${formattedName}`, t`Running ${formattedName}`);
};