feat(ai): humanize tool-call (#21976)

# Humanize tool-call labels

cc: https://github.com/twentyhq/twenty/pull/21462

## Preview
<img width="459" height="156" alt="Screenshot 2026-06-22 at 19 13 11"
src="https://github.com/user-attachments/assets/e7a2f5f5-cd09-4ec6-920b-5eb16b98285c"
/>
<img width="461" height="156" alt="Screenshot 2026-06-22 at 19 14 54"
src="https://github.com/user-attachments/assets/c2114d2e-2aa8-499a-9801-68e3bb7c45f8"
/>
<img width="461" height="505" alt="Screenshot 2026-06-22 at 19 15 01"
src="https://github.com/user-attachments/assets/ee9ca5d0-8e79-4c63-a2ff-ed5e359a9a9c"
/>

## Why

In the AI chat, tool steps were displayed using raw tool identifiers
(`find_many_companies`, `create_one_task`, `send_email`...) and labels
were partially reconstructed/humanized on the frontend. This was hard to
localize and inconsistent across tool categories.

This PR makes the **backend the single source of truth for
human-readable, localized tool labels**, exposes them through
`getToolIndex`, and reduces the frontend to a thin resolver that picks
the right label for the current status (in-progress / completed).

## What changed

### Backend

- `ToolIndexEntry` (and the `getToolIndex` GraphQL DTO) now carry
`label`, `inProgressLabel?`, `completedLabel?`.
- New `getCrudToolLabels(operation, objectLabel, i18nService, locale)`
builds CRUD labels from a verb table (Search / Find / Group / Create /
Update / Upsert / Delete × imperative / in-progress / completed) + the
(translated, lowercased) object label.
- New `translate-tool-label.util.ts` translates a source label via
`I18nService` (`generateMessageId` → fallback to source when no
translation exists).
- Action tools: labels extracted to the `ACTION_TOOL_LABELS` constant
(`msg` + `i18nLabel`) and translated in
`ActionToolProvider.buildDescriptor`.
- Logic-function tools use the function name as label;
`toolSetToDescriptors` (workflow / view / metadata / dashboard) accepts
an optional `labels` map and falls back to a humanized tool name.
- Labels are localized server-side using the request locale
(`@RequestLocale` → `buildToolIndex` → `context.locale`, threaded
through `ToolContext` / `ToolProviderContext`).
- `code_interpreter` schema now asks the model for `loadingMessage`
(present tense) and `completedMessage` (past tense), so its status text
is model-generated.
- Removed the old generic `loadingMessage` injection mechanism
(`wrap-tool-for-execution.util.ts` deleted; `wrapJsonSchemaForExecution`
/ `stripLoadingMessage` no longer wrap every tool).

### Frontend

- New `useToolLabelMap()` hook builds a `Map<name, { label,
inProgressLabel, completedLabel }>` from `getToolIndex`.
- `getToolDisplayMessage` → `resolveToolDisplayMessage({ input,
toolName, isFinished, labelMap, output })`: a small resolver registry
keyed by tool name (`execute_tool`, `web_search`, `learn_tools`,
`load_skills`, `code_interpreter`, default).
- Default resolver prefers backend `completedLabel` / `inProgressLabel`,
falling back to `Ran X` / `Running X`.
- `learn_tools` / `load_skills` resolve their inner tool/skill names to
labels (label map → tool output labels via `getToolOutputLabelEntries` →
raw name).
- `code_interpreter` step is now expandable to show the code even while
running.

## How tool labelling flows (BE → FE)

```text
BACKEND
┌───────────────────────────────────────────────────────────────────────────┐
│ Tool providers (per category) → ToolIndexEntry                              │
│                                                                             │
│  DatabaseToolProvider                                                       │
│    getCrudToolLabels(operation, object.labelPlural/Singular, i18n, locale)  │
│      verb table (Search/Create/Update/Delete…) + translateToolLabel(object) │
│      → { label, inProgressLabel, completedLabel }                           │
│                                                                             │
│  ActionToolProvider                                                         │
│    ACTION_TOOL_LABELS[toolId] (msg) → translateToolLabel(…, locale)         │
│      → { label, inProgressLabel?, completedLabel? }                         │
│                                                                             │
│  LogicFunctionToolProvider   → label = logicFunction.name                   │
│  toolSetToDescriptors        → label = labels[name] ?? humanize(name)       │
│  (workflow / view / metadata / dashboard)                                   │
└───────────────────────────────────────────────────────────────────────────┘
            │ 
            ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ GraphQL  Query getToolIndex : [ToolIndexEntry]                              │
│   { name, label, inProgressLabel, completedLabel, description,              │
│     category, objectName, icon }                                            │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
FRONTEND ─ resolve the right label for the current status
┌───────────────────────────────────────────────────────────────────────────┐
│ useGetToolIndex() → useToolLabelMap()                                       │
│   Map<name, { label, inProgressLabel?, completedLabel? }>                   │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ resolveToolDisplayMessage({ input, toolName, isFinished, labelMap, output })│
│                                                                             │
│   TOOL_LABEL_RESOLVERS[toolName] ?? defaultResolver                         │
│   ├─ execute_tool     → unwrap { toolName, arguments } then re-resolve      │
│   ├─ web_search       → "Searching/Searched the web for <query>"           │
│   ├─ learn_tools      → "Learning/Learned <labels>"                         │
│   ├─ load_skills      → "Loading/Loaded <labels>"                           │
│   │     inner names resolved via: labelMap → output labels → raw name       │
│   ├─ code_interpreter → model's loadingMessage / completedMessage           │
│   └─ default          → isFinished                                          │
│                           ? completedLabel ?? "Ran <label>"                 │
│                           : inProgressLabel ?? "Running <label>"            │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
   Rendered by ThinkingStepsDisplay / ToolStepRenderer
```

## Localization notes

- Standard object labels and action/CRUD verbs are translated
server-side via `I18nService` using the requester's locale.
- Custom object labels are not translated unless a workspace custom
translation exists (matched by `generateMessageId`); otherwise the
source label is used as-is.

## Tests

- **FE:** `resolveToolDisplayMessage` / `getToolOutputLabelEntries`
(status selection, inner-name resolution, `code_interpreter` model
labels, fallbacks).
- **BE:** `toolSetToDescriptors` (label map + humanized fallback) and
`database-tool.provider` label generation.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21976?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:
Etienne
2026-06-24 13:41:09 +02:00
committed by GitHub
parent 680e4a712b
commit 5ca41d55fb
70 changed files with 2039 additions and 548 deletions
@@ -2656,6 +2656,7 @@ type Webhook {
type ToolIndexEntry {
name: String!
label: String!
description: String!
category: String!
objectName: String
@@ -2304,6 +2304,7 @@ export interface Webhook {
export interface ToolIndexEntry {
name: Scalars['String']
label: Scalars['String']
description: Scalars['String']
category: Scalars['String']
objectName?: Scalars['String']
@@ -5424,6 +5425,7 @@ export interface WebhookGenqlSelection{
export interface ToolIndexEntryGenqlSelection{
name?: boolean | number
label?: boolean | number
description?: boolean | number
category?: boolean | number
objectName?: boolean | number
@@ -5203,6 +5203,9 @@ export default {
"name": [
1
],
"label": [
1
],
"description": [
1
],
@@ -5170,6 +5170,7 @@ export type ToolIndexEntry = {
description: Scalars['String']['output'];
icon?: Maybe<Scalars['String']['output']>;
inputSchema?: Maybe<Scalars['JSON']['output']>;
label: Scalars['String']['output'];
name: Scalars['String']['output'];
objectName?: Maybe<Scalars['String']['output']>;
};
@@ -6557,7 +6558,7 @@ export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ _
export type GetToolIndexQueryVariables = Exact<{ [key: string]: never; }>;
export type GetToolIndexQuery = { __typename?: 'Query', getToolIndex: Array<{ __typename?: 'ToolIndexEntry', name: string, description: string, category: string, objectName?: string | null, icon?: string | null }> };
export type GetToolIndexQuery = { __typename?: 'Query', getToolIndex: Array<{ __typename?: 'ToolIndexEntry', name: string, label: string, description: string, category: string, objectName?: string | null, icon?: string | null }> };
export type GetToolInputSchemaQueryVariables = Exact<{
toolName: Scalars['String']['input'];
@@ -8714,7 +8715,7 @@ export const FindWorkspaceAiStatsDocument = {"kind":"Document","definitions":[{"
export const GetAgentTurnsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAgentTurns"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"agentTurns"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"agentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"agentId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"evaluations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"providerExecuted"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetAgentTurnsQuery, GetAgentTurnsQueryVariables>;
export const GetChatMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"turnId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"orderIndex"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"providerExecuted"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"fileId"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"chatStreamCatchupChunks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chunks"}},{"kind":"Field","name":{"kind":"Name","value":"maxSeq"}}]}}]}}]} as unknown as DocumentNode<GetChatMessagesQuery, GetChatMessagesQueryVariables>;
export const GetChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetChatThreadsQuery, GetChatThreadsQueryVariables>;
export const GetToolIndexDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"objectName"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}}]}}]} as unknown as DocumentNode<GetToolIndexQuery, GetToolIndexQueryVariables>;
export const GetToolIndexDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getToolIndex"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"objectName"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}}]}}]} as unknown as DocumentNode<GetToolIndexQuery, GetToolIndexQueryVariables>;
export const GetToolInputSchemaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetToolInputSchema"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toolName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getToolInputSchema"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"toolName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toolName"}}}]}]}}]} as unknown as DocumentNode<GetToolInputSchemaQuery, GetToolInputSchemaQueryVariables>;
export const OnAgentChatEventDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnAgentChatEvent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onAgentChatEvent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"event"}}]}}]}}]} as unknown as DocumentNode<OnAgentChatEventSubscription, OnAgentChatEventSubscriptionVariables>;
export const TrackAnalyticsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TrackAnalytics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"type"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AnalyticsType"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"event"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"properties"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"trackAnalytics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"type"},"value":{"kind":"Variable","name":{"kind":"Name","value":"type"}}},{"kind":"Argument","name":{"kind":"Name","value":"event"},"value":{"kind":"Variable","name":{"kind":"Name","value":"event"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"properties"},"value":{"kind":"Variable","name":{"kind":"Name","value":"properties"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode<TrackAnalyticsMutation, TrackAnalyticsMutationVariables>;
@@ -14,11 +14,10 @@ import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type JsonValue } from 'type-fest';
import { useToolDisplayContext } from '@/ai/hooks/useToolDisplayContext';
import { getToolIcon } from '@/ai/utils/getToolIcon';
import {
getToolDisplayMessage,
resolveToolInput,
} from '@/ai/utils/getToolDisplayMessage';
import { getToolDisplayMessage } from '@/ai/utils/tool-display/get-tool-display-message';
import { unwrapToolInput } from '@/ai/utils/tool-display/unwrap-tool-input.util';
import { getActiveReasoningContent } from '@/ai/utils/getActiveReasoningContent';
import { getLastReasoningContent } from '@/ai/utils/getLastReasoningContent';
import { isThinkingStepPartActive } from '@/ai/utils/isThinkingStepPartActive';
@@ -263,13 +262,20 @@ const ThinkingToolStepRow = ({
const { copyToClipboard } = useCopyToClipboard();
const [isExpanded, setIsExpanded] = useState(false);
const rawToolName = getToolName(part);
const { resolvedInput: toolInput, resolvedToolName } = resolveToolInput(
part.input,
rawToolName,
);
const { toolInput, toolName } = unwrapToolInput({
input: part.input,
toolName: rawToolName,
});
const ToolIcon = getToolIcon(resolvedToolName);
const label = getToolDisplayMessage(part.input, rawToolName, !isActive);
const displayContext = useToolDisplayContext();
const ToolIcon = getToolIcon(toolName);
const displayMessage = getToolDisplayMessage({
input: part.input,
toolName: rawToolName,
isFinished: !isActive,
displayContext,
output: part.output,
});
const hasError = isDefined(part.errorText);
const isExpandable = isDefined(part.output) || hasError;
@@ -312,7 +318,7 @@ const ThinkingToolStepRow = ({
<StyledRowLabelContainer>
<StyledToolRowLabel>
<OverflowingTextWithTooltip
text={label}
text={displayMessage}
tooltipDelay={TooltipDelay.shortDelay}
/>
</StyledToolRowLabel>
@@ -8,10 +8,9 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
import { ShimmeringText } from '@/ai/components/ShimmeringText';
import {
getToolDisplayMessage,
resolveToolInput,
} from '@/ai/utils/getToolDisplayMessage';
import { useToolDisplayContext } from '@/ai/hooks/useToolDisplayContext';
import { getToolDisplayMessage } from '@/ai/utils/tool-display/get-tool-display-message';
import { unwrapToolInput } from '@/ai/utils/tool-display/unwrap-tool-input.util';
import { getToolIcon } from '@/ai/utils/getToolIcon';
import { useLingui } from '@lingui/react/macro';
import { type DynamicToolUIPart, getToolName, type ToolUIPart } from 'ai';
@@ -143,11 +142,15 @@ export const ToolStepRenderer = ({
const { input, output, errorText } = toolPart;
const rawToolName = getToolName(toolPart);
const { resolvedInput: toolInput, resolvedToolName: toolName } =
resolveToolInput(input, rawToolName);
const { toolInput, toolName } = unwrapToolInput({
input,
toolName: rawToolName,
});
const displayContext = useToolDisplayContext();
const hasError = isDefined(errorText);
const isExpandable = isDefined(output) || hasError;
const isCodeInterpreter = toolName === 'code_interpreter';
const isExpandable = isDefined(output) || hasError || isCodeInterpreter;
const ToolIcon = getToolIcon(toolName);
const outputObj =
@@ -159,42 +162,40 @@ export const ToolStepRenderer = ({
const toolError =
typeof outputObj?.error === 'string' ? outputObj.error : null;
if (toolName === 'code_interpreter') {
const codeInput = toolInput as { code?: string } | undefined;
const codeOutput = outputObj as {
stdout?: string;
stderr?: string;
exitCode?: number;
files?: Array<{
fileId: string;
filename: string;
url: string;
mimeType?: string;
}>;
} | null;
const isRunning = !outputObj && !hasError && isStreaming;
return (
<CodeExecutionDisplay
code={codeInput?.code ?? ''}
stdout={codeOutput?.stdout ?? ''}
stderr={codeOutput?.stderr || errorText || ''}
exitCode={codeOutput?.exitCode}
files={codeOutput?.files}
isRunning={isRunning}
/>
);
}
const codeInput = isCodeInterpreter
? (toolInput as { code?: string } | undefined)
: null;
const codeOutput = isCodeInterpreter
? (outputObj as {
stdout?: string;
stderr?: string;
exitCode?: number;
files?: Array<{
fileId: string;
filename: string;
url: string;
mimeType?: string;
}>;
} | null)
: null;
if (!output && !hasError) {
const displayText = isStreaming
? getToolDisplayMessage(input, rawToolName, false)
: getToolDisplayMessage(input, rawToolName, true);
const displayText = getToolDisplayMessage({
input,
toolName: rawToolName,
isFinished: !isStreaming,
displayContext,
output,
});
return (
<StyledContainer>
<StyledToggleButton isExpandable={false}>
<StyledToggleButton
isExpandable={isCodeInterpreter}
onClick={
isCodeInterpreter ? () => setIsExpanded(!isExpanded) : undefined
}
>
<StyledLeftContent>
<StyledIconTextContainer>
<ToolIcon size={theme.icon.size.sm} />
@@ -209,8 +210,27 @@ export const ToolStepRenderer = ({
</StyledLeftContent>
<StyledRightContent>
<StyledToolName>{toolName}</StyledToolName>
{isCodeInterpreter &&
(isExpanded ? (
<IconChevronUp size={theme.icon.size.sm} />
) : (
<IconChevronDown size={theme.icon.size.sm} />
))}
</StyledRightContent>
</StyledToggleButton>
{isCodeInterpreter && (
<AnimatedExpandableContainer
isExpanded={isExpanded}
mode="fit-content"
>
<CodeExecutionDisplay
code={codeInput?.code ?? ''}
stdout=""
stderr=""
isRunning={isStreaming}
/>
</AnimatedExpandableContainer>
)}
</StyledContainer>
);
}
@@ -220,11 +240,74 @@ export const ToolStepRenderer = ({
: rawToolName === 'learn_tools' ||
rawToolName === 'execute_tool' ||
rawToolName === 'load_skills'
? getToolDisplayMessage(input, rawToolName, true)
: (toolMessage ?? getToolDisplayMessage(input, rawToolName, true));
? getToolDisplayMessage({
input,
toolName: rawToolName,
isFinished: true,
displayContext,
output,
})
: (toolMessage ??
getToolDisplayMessage({
input,
toolName: rawToolName,
isFinished: true,
displayContext,
output,
}));
const result = toolError ? { error: toolError } : outputObj;
const renderExpandedContent = () => {
if (isCodeInterpreter) {
return (
<CodeExecutionDisplay
code={codeInput?.code ?? ''}
stdout={codeOutput?.stdout ?? ''}
stderr={codeOutput?.stderr || errorText || ''}
exitCode={codeOutput?.exitCode}
files={codeOutput?.files}
/>
);
}
if (hasError) {
return errorText;
}
return (
<>
<StyledTabContainer>
<StyledTab
isActive={activeTab === 'output'}
onClick={() => setActiveTab('output')}
>
{t`Output`}
</StyledTab>
<StyledTab
isActive={activeTab === 'input'}
onClick={() => setActiveTab('input')}
>
{t`Input`}
</StyledTab>
</StyledTabContainer>
<StyledJsonTreeContainer>
<JsonTree
value={(activeTab === 'output' ? result : toolInput) as JsonValue}
shouldExpandNodeInitially={() => false}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
onNodeValueClick={copyToClipboard}
/>
</StyledJsonTreeContainer>
</>
);
};
return (
<StyledContainer>
<StyledToggleButton
@@ -250,43 +333,13 @@ export const ToolStepRenderer = ({
{isExpandable && (
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
<StyledContentContainer>
{hasError ? (
errorText
) : (
<>
<StyledTabContainer>
<StyledTab
isActive={activeTab === 'output'}
onClick={() => setActiveTab('output')}
>
{t`Output`}
</StyledTab>
<StyledTab
isActive={activeTab === 'input'}
onClick={() => setActiveTab('input')}
>
{t`Input`}
</StyledTab>
</StyledTabContainer>
<StyledJsonTreeContainer>
<JsonTree
value={
(activeTab === 'output' ? result : toolInput) as JsonValue
}
shouldExpandNodeInitially={() => false}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
onNodeValueClick={copyToClipboard}
/>
</StyledJsonTreeContainer>
</>
)}
</StyledContentContainer>
{isCodeInterpreter ? (
renderExpandedContent()
) : (
<StyledContentContainer>
{renderExpandedContent()}
</StyledContentContainer>
)}
</AnimatedExpandableContainer>
)}
</StyledContainer>
@@ -139,7 +139,7 @@ describe('AiChatAssistantMessageRenderer', () => {
expect(screen.getByTestId('code-execution-display')).toBeInTheDocument();
});
it('should render tool-execute_tool wrapping code_interpreter via ToolStepRenderer after refetch', () => {
it('should render tool-execute_tool wrapping code_interpreter via ThinkingStepsDisplay after refetch', () => {
const messageParts = [
{
type: 'tool-execute_tool',
@@ -157,10 +157,10 @@ describe('AiChatAssistantMessageRenderer', () => {
renderAssistantRenderer(messageParts);
expect(screen.queryByTestId('thinking-steps-display')).toBeNull();
expect(screen.getByTestId('tool-step-renderer')).toHaveTextContent(
'tool-execute_tool',
expect(screen.getByTestId('thinking-steps-display')).toHaveTextContent(
'thinking-1-answer-pending',
);
expect(screen.queryByTestId('tool-step-renderer')).toBeNull();
});
it('should hide execute_tool wrapping code_interpreter when data-code-execution parts exist', () => {
@@ -11,6 +11,14 @@ jest.mock('~/hooks/useCopyToClipboard', () => ({
}),
}));
jest.mock('@/ai/hooks/useGetToolIndex', () => ({
useGetToolIndex: () => ({
toolIndex: [],
loading: false,
error: undefined,
}),
}));
jest.mock(
'@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue',
() => ({
@@ -0,0 +1,22 @@
import { msg } from '@lingui/core/macro';
import { type ToolStatusLabels } from '@/ai/types/tool-status-labels.type';
export const ACTION_TOOL_STATUS_LABELS: Record<string, ToolStatusLabels> = {
send_email: {
loading: msg`Sending email`,
completed: msg`Sent email`,
},
draft_email: {
loading: msg`Drafting email`,
completed: msg`Drafted email`,
},
search_help_center: {
loading: msg`Searching the help center`,
completed: msg`Searched the help center`,
},
navigate_app: {
loading: msg`Navigating in the app`,
completed: msg`Navigated in the app`,
},
};
@@ -0,0 +1,52 @@
import { msg } from '@lingui/core/macro';
import { type DatabaseCrudOperation } from 'twenty-shared/ai';
import { type ToolStatusLabels } from '@/ai/types/tool-status-labels.type';
export type CrudToolOperation = DatabaseCrudOperation;
export const CRUD_TOOL_OPERATION_VERBS: Record<
CrudToolOperation,
ToolStatusLabels
> = {
find_many: {
loading: msg`Searching {objectLabel}`,
completed: msg`Searched {objectLabel}`,
},
find_one: {
loading: msg`Finding {objectLabel}`,
completed: msg`Found {objectLabel}`,
},
group_by: {
loading: msg`Grouping {objectLabel}`,
completed: msg`Grouped {objectLabel}`,
},
create_one: {
loading: msg`Creating {objectLabel}`,
completed: msg`Created {objectLabel}`,
},
create_many: {
loading: msg`Creating {objectLabel}`,
completed: msg`Created {objectLabel}`,
},
update_one: {
loading: msg`Updating {objectLabel}`,
completed: msg`Updated {objectLabel}`,
},
update_many: {
loading: msg`Updating {objectLabel}`,
completed: msg`Updated {objectLabel}`,
},
upsert_many: {
loading: msg`Upserting {objectLabel}`,
completed: msg`Upserted {objectLabel}`,
},
delete_one: {
loading: msg`Deleting {objectLabel}`,
completed: msg`Deleted {objectLabel}`,
},
delete_many: {
loading: msg`Deleting {objectLabel}`,
completed: msg`Deleted {objectLabel}`,
},
};
@@ -4,6 +4,7 @@ export const GET_TOOL_INDEX = gql`
query GetToolIndex {
getToolIndex {
name
label
description
category
objectName
@@ -3,11 +3,13 @@ import { useQuery } from '@apollo/client/react';
import { type GetToolIndexQuery } from '~/generated-metadata/graphql';
const EMPTY_TOOL_INDEX: NonNullable<GetToolIndexQuery['getToolIndex']> = [];
export const useGetToolIndex = () => {
const { data, loading, error } = useQuery<GetToolIndexQuery>(GET_TOOL_INDEX);
return {
toolIndex: data?.getToolIndex ?? [],
toolIndex: data?.getToolIndex ?? EMPTY_TOOL_INDEX,
loading,
error,
};
@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex';
import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type ToolCategory } from 'twenty-shared/ai';
const EMPTY_TOOL_DISPLAY_CONTEXT: ToolDisplayContext = {
labelByName: new Map(),
indexByName: new Map(),
objectMetadataItems: [],
};
export const useToolDisplayContext = (): ToolDisplayContext => {
const { toolIndex } = useGetToolIndex();
const { objectMetadataItems } = useObjectMetadataItems();
return useMemo(() => {
if (toolIndex.length === 0 && objectMetadataItems.length === 0) {
return EMPTY_TOOL_DISPLAY_CONTEXT;
}
return {
labelByName: new Map(toolIndex.map((entry) => [entry.name, entry.label])),
indexByName: new Map(
toolIndex.map((entry) => [
entry.name,
{
category: entry.category as ToolCategory,
objectName: entry.objectName,
},
]),
),
objectMetadataItems,
};
}, [toolIndex, objectMetadataItems]);
};
@@ -0,0 +1,14 @@
import { type ToolCategory } from 'twenty-shared/ai';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
type ToolIndexInfo = {
category: ToolCategory;
objectName?: string | null;
};
export type ToolDisplayContext = {
labelByName: Map<string, string>;
indexByName: Map<string, ToolIndexInfo>;
objectMetadataItems: EnrichedObjectMetadataItem[];
};
@@ -0,0 +1,6 @@
import { type MessageDescriptor } from '@lingui/core';
export type ToolStatusLabels = {
loading: MessageDescriptor;
completed: MessageDescriptor;
};
@@ -1,194 +0,0 @@
import { i18n } from '@lingui/core';
import {
getToolDisplayMessage,
resolveToolInput,
} from '@/ai/utils/getToolDisplayMessage';
beforeEach(() => {
i18n.load('en', {});
i18n.activate('en');
});
describe('resolveToolInput', () => {
it('should pass through non-execute_tool inputs unchanged', () => {
const input = { query: 'test' };
const result = resolveToolInput(input, 'web_search');
expect(result).toEqual({
resolvedInput: input,
resolvedToolName: 'web_search',
});
});
it('should unwrap execute_tool input', () => {
const input = {
toolName: 'find_many_companies',
arguments: { filter: { name: 'Acme' } },
};
const result = resolveToolInput(input, 'execute_tool');
expect(result).toEqual({
resolvedInput: { filter: { name: 'Acme' } },
resolvedToolName: 'find_many_companies',
});
});
it('should return original input for non-execute_tool even with toolName field', () => {
const input = { toolName: 'inner', arguments: {} };
const result = resolveToolInput(input, 'web_search');
expect(result).toEqual({
resolvedInput: input,
resolvedToolName: 'web_search',
});
});
});
describe('getToolDisplayMessage', () => {
describe('web_search', () => {
it('should show finished message with query', () => {
const message = getToolDisplayMessage(
{ query: 'CRM tools' },
'web_search',
true,
);
expect(message).toContain('Searched');
expect(message).toContain('CRM tools');
});
it('should show in-progress message with query', () => {
const message = getToolDisplayMessage(
{ query: 'CRM tools' },
'web_search',
false,
);
expect(message).toContain('Searching');
expect(message).toContain('CRM tools');
});
it('should handle nested query format', () => {
const message = getToolDisplayMessage(
{ action: { query: 'nested query' } },
'web_search',
true,
);
expect(message).toContain('nested query');
});
it('should handle missing query', () => {
const message = getToolDisplayMessage({}, 'web_search', true);
expect(message).toContain('Searched the web');
});
});
describe('app_exa_web_search', () => {
it('should show the same searching-the-web message as native web_search', () => {
const message = getToolDisplayMessage(
{ query: 'CRM tools' },
'app_exa_web_search',
false,
);
expect(message).toContain('Searching');
expect(message).toContain('CRM tools');
});
it('should handle missing query', () => {
const message = getToolDisplayMessage({}, 'app_exa_web_search', true);
expect(message).toContain('Searched the web');
});
});
describe('learn_tools', () => {
it('should show tool names when provided', () => {
const message = getToolDisplayMessage(
{ toolNames: ['find_many_companies', 'create_one_task'] },
'learn_tools',
true,
);
expect(message).toContain('Learned');
expect(message).toContain('find_many_companies, create_one_task');
});
it('should show generic message without tool names', () => {
const message = getToolDisplayMessage({}, 'learn_tools', true);
expect(message).toContain('Learned tools');
});
});
describe('load_skills', () => {
it('should show skill names when provided', () => {
const message = getToolDisplayMessage(
{ skillNames: ['data-manipulation'] },
'load_skills',
false,
);
expect(message).toContain('Loading');
expect(message).toContain('data-manipulation');
});
it('should show generic message without skill names', () => {
const message = getToolDisplayMessage({}, 'load_skills', true);
expect(message).toContain('Loaded skills');
});
});
describe('custom loading message', () => {
it('should use loadingMessage when provided', () => {
const message = getToolDisplayMessage(
{ loadingMessage: 'Building dashboard...' },
'some_tool',
false,
);
expect(message).toBe('Building dashboard...');
});
});
describe('generic tools', () => {
it('should format tool name with spaces for finished state', () => {
const message = getToolDisplayMessage(
{},
'create_complete_dashboard',
true,
);
expect(message).toContain('Ran');
expect(message).toContain('create complete dashboard');
});
it('should format tool name with spaces for in-progress state', () => {
const message = getToolDisplayMessage(
{},
'create_complete_dashboard',
false,
);
expect(message).toContain('Running');
expect(message).toContain('create complete dashboard');
});
});
describe('execute_tool wrapper', () => {
it('should unwrap execute_tool and display inner tool name', () => {
const message = getToolDisplayMessage(
{ toolName: 'find_many_companies', arguments: { limit: 10 } },
'execute_tool',
true,
);
expect(message).toContain('Ran');
expect(message).toContain('find many companies');
});
});
});
@@ -0,0 +1,49 @@
import { getToolOutputLabelEntries } from '@/ai/utils/getToolOutputLabelEntries';
describe('getToolOutputLabelEntries', () => {
it('should extract label entries from a learn_tools output', () => {
const entries = getToolOutputLabelEntries({
tools: [
{ name: 'find_many_companies', label: 'Search companies' },
{ name: 'create_one_task', label: 'Create task' },
],
notFound: [],
message: 'Learned 2 tools',
});
expect(entries).toEqual([
{ name: 'find_many_companies', label: 'Search companies' },
{ name: 'create_one_task', label: 'Create task' },
]);
});
it('should extract label entries from a load_skills output', () => {
const entries = getToolOutputLabelEntries({
skills: [
{ name: 'data-manipulation', label: 'Data Manipulation' },
{ name: 'workflow-building', label: 'Workflow Building' },
],
message: 'Loaded Data Manipulation, Workflow Building',
});
expect(entries).toEqual([
{ name: 'data-manipulation', label: 'Data Manipulation' },
{ name: 'workflow-building', label: 'Workflow Building' },
]);
});
it('should skip entries without a label', () => {
const entries = getToolOutputLabelEntries({
skills: [{ name: 'data-manipulation' }],
});
expect(entries).toEqual([]);
});
it('should return an empty array for unrelated outputs', () => {
expect(getToolOutputLabelEntries({ records: [], count: '0' })).toEqual([]);
expect(getToolOutputLabelEntries(null)).toEqual([]);
expect(getToolOutputLabelEntries(undefined)).toEqual([]);
expect(getToolOutputLabelEntries('not an object')).toEqual([]);
});
});
@@ -41,7 +41,7 @@ const createToolPart = ({
describe('thinkingStepsDisplayState', () => {
describe('groupContiguousThinkingStepParts', () => {
it('should group contiguous reasoning and non-code-interpreter tool parts', () => {
it('should group contiguous reasoning and tool parts', () => {
const parts = [
{ type: 'text', text: 'hello' } as ExtendedUIMessagePart,
{ type: 'step-start' } as ExtendedUIMessagePart,
@@ -77,9 +77,9 @@ describe('thinkingStepsDisplayState', () => {
type: 'part',
part: parts[6],
});
expect(groupedParts[3]).toEqual({
type: 'part',
part: parts[7],
expect(groupedParts[3]).toMatchObject({
type: 'thinking-steps',
parts: [parts[7]],
});
});
});
@@ -1,134 +0,0 @@
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}`);
};
@@ -0,0 +1,25 @@
import { z } from 'zod';
const LabelEntrySchema = z.object({
name: z.string(),
label: z.string().optional(),
});
const ToolOutputWithLabelsSchema = z.object({
tools: z.array(LabelEntrySchema).optional(),
skills: z.array(LabelEntrySchema).optional(),
});
export const getToolOutputLabelEntries = (
output: unknown,
): Array<{ name: string; label: string }> => {
const parsed = ToolOutputWithLabelsSchema.safeParse(output);
if (!parsed.success) {
return [];
}
return [...(parsed.data.tools ?? []), ...(parsed.data.skills ?? [])].flatMap(
(entry) => (entry.label ? [{ name: entry.name, label: entry.label }] : []),
);
};
@@ -1,7 +1,6 @@
import { isToolUIPart } from 'ai';
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { isCodeInterpreterToolPart } from '@/ai/utils/isCodeInterpreterToolPart';
import { type ThinkingStepPart } from '@/ai/utils/thinkingStepPart';
export const isThinkingStepPart = (
@@ -11,5 +10,5 @@ export const isThinkingStepPart = (
return true;
}
return isToolUIPart(part) && !isCodeInterpreterToolPart(part);
return isToolUIPart(part);
};
@@ -0,0 +1,58 @@
import { i18n } from '@lingui/core';
import { buildActionToolStatusMessage } from '@/ai/utils/tool-display/build-action-tool-status-message.util';
import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type';
beforeEach(() => {
i18n.load('en', {});
i18n.activate('en');
});
describe('buildActionToolStatusMessage', () => {
it('should use custom status labels when defined', () => {
const displayContext: ToolDisplayContext = {
labelByName: new Map([['send_email', 'Send Email']]),
indexByName: new Map(),
objectMetadataItems: [],
};
expect(
buildActionToolStatusMessage({
toolName: 'send_email',
isFinished: false,
displayContext,
}),
).toBe('Sending email');
expect(
buildActionToolStatusMessage({
toolName: 'send_email',
isFinished: true,
displayContext,
}),
).toBe('Sent email');
});
it('should fall back to default Ran/Running when no custom status labels exist', () => {
const displayContext: ToolDisplayContext = {
labelByName: new Map([['http_request', 'HTTP Request']]),
indexByName: new Map(),
objectMetadataItems: [],
};
expect(
buildActionToolStatusMessage({
toolName: 'http_request',
isFinished: false,
displayContext,
}),
).toContain('Running');
expect(
buildActionToolStatusMessage({
toolName: 'http_request',
isFinished: true,
displayContext,
}),
).toContain('Ran');
});
});
@@ -0,0 +1,58 @@
import { i18n } from '@lingui/core';
import { ToolCategory } from 'twenty-shared/ai';
import { buildCrudToolStatusMessage } from '@/ai/utils/tool-display/build-crud-tool-status-message.util';
import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type';
beforeEach(() => {
i18n.load('en', {});
i18n.activate('en');
});
describe('buildCrudToolStatusMessage', () => {
const displayContext: ToolDisplayContext = {
labelByName: new Map([['find_many_people', 'Search people']]),
indexByName: new Map([
[
'find_many_people',
{ category: ToolCategory.DATABASE_CRUD, objectName: 'person' },
],
]),
objectMetadataItems: [
{
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Contact',
labelPlural: 'Contacts',
},
] as ToolDisplayContext['objectMetadataItems'],
};
it('should build in-progress and completed labels from metadata', () => {
expect(
buildCrudToolStatusMessage({
toolName: 'find_many_people',
isFinished: false,
displayContext,
}),
).toBe('Searching contacts');
expect(
buildCrudToolStatusMessage({
toolName: 'find_many_people',
isFinished: true,
displayContext,
}),
).toBe('Searched contacts');
});
it('should return null for non-CRUD tool names', () => {
expect(
buildCrudToolStatusMessage({
toolName: 'send_email',
isFinished: true,
displayContext,
}),
).toBeNull();
});
});
@@ -0,0 +1,589 @@
import { i18n } from '@lingui/core';
import { ToolCategory } from 'twenty-shared/ai';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getToolDisplayMessage } from '@/ai/utils/tool-display/get-tool-display-message';
import { unwrapToolInput } from '@/ai/utils/tool-display/unwrap-tool-input.util';
import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type';
const emptyDisplayContext: ToolDisplayContext = {
labelByName: new Map(),
indexByName: new Map(),
objectMetadataItems: [],
};
const makeDisplayContext = ({
labels = [],
indexEntries = [],
objectMetadataItems = [],
}: {
labels?: Array<[string, string]>;
indexEntries?: Array<
[string, { category: ToolCategory; objectName?: string | null }]
>;
objectMetadataItems?: Array<
Pick<
EnrichedObjectMetadataItem,
'nameSingular' | 'namePlural' | 'labelSingular' | 'labelPlural'
>
>;
}): ToolDisplayContext => ({
labelByName: new Map(labels),
indexByName: new Map(indexEntries),
objectMetadataItems: objectMetadataItems as EnrichedObjectMetadataItem[],
});
const personMetadata = {
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
};
beforeEach(() => {
i18n.load('en', {});
i18n.activate('en');
});
describe('unwrapToolInput', () => {
it('should pass through non-execute_tool inputs unchanged', () => {
const input = { query: 'test' };
const result = unwrapToolInput({ input, toolName: 'web_search' });
expect(result).toEqual({
toolInput: input,
toolName: 'web_search',
});
});
it('should unwrap execute_tool input', () => {
const input = {
toolName: 'find_many_companies',
arguments: { filter: { name: 'Acme' } },
};
const result = unwrapToolInput({ input, toolName: 'execute_tool' });
expect(result).toEqual({
toolInput: { filter: { name: 'Acme' } },
toolName: 'find_many_companies',
});
});
it('should return original input for non-execute_tool even with toolName field', () => {
const input = { toolName: 'inner', arguments: {} };
const result = unwrapToolInput({ input, toolName: 'web_search' });
expect(result).toEqual({
toolInput: input,
toolName: 'web_search',
});
});
});
describe('getToolDisplayMessage', () => {
describe('web_search', () => {
it('should show finished message with query', () => {
const message = getToolDisplayMessage({
input: { query: 'CRM tools' },
toolName: 'web_search',
isFinished: true,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Searched');
expect(message).toContain('CRM tools');
});
it('should show in-progress message with query', () => {
const message = getToolDisplayMessage({
input: { query: 'CRM tools' },
toolName: 'web_search',
isFinished: false,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Searching');
expect(message).toContain('CRM tools');
});
it('should handle nested query format', () => {
const message = getToolDisplayMessage({
input: { action: { query: 'nested query' } },
toolName: 'web_search',
isFinished: true,
displayContext: emptyDisplayContext,
});
expect(message).toContain('nested query');
});
it('should handle missing query', () => {
const message = getToolDisplayMessage({
input: {},
toolName: 'web_search',
isFinished: true,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Searched the web');
});
});
describe('app_exa_web_search', () => {
it('should show the same searching-the-web message as native web_search', () => {
const message = getToolDisplayMessage({
input: { query: 'CRM tools' },
toolName: 'app_exa_web_search',
isFinished: false,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Searching');
expect(message).toContain('CRM tools');
});
it('should handle missing query', () => {
const message = getToolDisplayMessage({
input: {},
toolName: 'app_exa_web_search',
isFinished: true,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Searched the web');
});
});
describe('learn_tools', () => {
it('should show tool names when provided', () => {
const message = getToolDisplayMessage({
input: { toolNames: ['find_many_companies', 'create_one_task'] },
toolName: 'learn_tools',
isFinished: true,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Learned');
expect(message).toContain('find_many_companies');
expect(message).toContain('create_one_task');
});
it('should show generic message without tool names', () => {
const message = getToolDisplayMessage({
input: {},
toolName: 'learn_tools',
isFinished: true,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Learned tools');
});
it('should resolve inner tool names to their labels from the label map', () => {
const displayContext = makeDisplayContext({
labels: [
['find_many_companies', 'Search companies'],
['create_one_task', 'Create task'],
],
});
const message = getToolDisplayMessage({
input: { toolNames: ['find_many_companies', 'create_one_task'] },
toolName: 'learn_tools',
isFinished: true,
displayContext,
});
expect(message).toContain('Search companies');
expect(message).toContain('Create task');
});
it('should fall back to the raw tool name when the label map has no entry', () => {
const displayContext = makeDisplayContext({
labels: [['find_many_companies', 'Search companies']],
});
const message = getToolDisplayMessage({
input: { toolNames: ['find_many_companies', 'app_unknown_tool'] },
toolName: 'learn_tools',
isFinished: true,
displayContext,
});
expect(message).toContain('Search companies');
expect(message).toContain('app_unknown_tool');
});
it('should resolve labels from output when not in the label map', () => {
const output = {
tools: [{ name: 'app_unknown_tool', label: 'Unknown Tool Label' }],
};
const message = getToolDisplayMessage({
input: { toolNames: ['app_unknown_tool'] },
toolName: 'learn_tools',
isFinished: true,
displayContext: emptyDisplayContext,
output,
});
expect(message).toContain('Unknown Tool Label');
});
});
describe('load_skills', () => {
it('should show skill names when provided', () => {
const message = getToolDisplayMessage({
input: { skillNames: ['data-manipulation'] },
toolName: 'load_skills',
isFinished: false,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Loading');
expect(message).toContain('data-manipulation');
});
it('should show generic message without skill names', () => {
const message = getToolDisplayMessage({
input: {},
toolName: 'load_skills',
isFinished: true,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Loaded skills');
});
it('should resolve inner skill names from output labels', () => {
const output = {
skills: [{ name: 'data-manipulation', label: 'Data manipulation' }],
};
const message = getToolDisplayMessage({
input: { skillNames: ['data-manipulation'] },
toolName: 'load_skills',
isFinished: true,
displayContext: emptyDisplayContext,
output,
});
expect(message).toContain('Data manipulation');
});
});
describe('code_interpreter (model-generated labels)', () => {
it('should use loadingMessage when in progress', () => {
const message = getToolDisplayMessage({
input: { code: 'print(1)', loadingMessage: 'Analyzing sales data' },
toolName: 'code_interpreter',
isFinished: false,
displayContext: emptyDisplayContext,
});
expect(message).toBe('Analyzing sales data');
});
it('should use completedMessage when finished', () => {
const message = getToolDisplayMessage({
input: {
code: 'print(1)',
loadingMessage: 'Analyzing sales data',
completedMessage: 'Analyzed sales data',
},
toolName: 'code_interpreter',
isFinished: true,
displayContext: emptyDisplayContext,
});
expect(message).toBe('Analyzed sales data');
});
it('should fall back to loadingMessage when completedMessage is missing', () => {
const message = getToolDisplayMessage({
input: { code: 'print(1)', loadingMessage: 'Analyzing sales data' },
toolName: 'code_interpreter',
isFinished: true,
displayContext: emptyDisplayContext,
});
expect(message).toBe('Analyzing sales data');
});
it('should fall back to generic label when loadingMessage is absent', () => {
const message = getToolDisplayMessage({
input: { code: 'print(1)' },
toolName: 'code_interpreter',
isFinished: false,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Running code');
});
it('should fall back to generic label when loadingMessage is empty', () => {
const message = getToolDisplayMessage({
input: { code: 'print(1)', loadingMessage: '' },
toolName: 'code_interpreter',
isFinished: false,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Running code');
});
it('should not use loadingMessage for non-code_interpreter tools', () => {
const message = getToolDisplayMessage({
input: { loadingMessage: 'Some status' },
toolName: 'some_tool',
isFinished: false,
displayContext: emptyDisplayContext,
});
expect(message).not.toBe('Some status');
expect(message).toContain('Running');
expect(message).toContain('some_tool');
});
});
describe('default tool labels', () => {
it('should use default Ran/Running for non-CRUD tools', () => {
const displayContext = makeDisplayContext({
labels: [['create_complete_dashboard', 'Create Dashboard']],
});
const message = getToolDisplayMessage({
input: {},
toolName: 'create_complete_dashboard',
isFinished: true,
displayContext,
});
expect(message).toContain('Ran');
expect(message).toContain('Create Dashboard');
});
it('should fall back to tool name when label map has no entry', () => {
const message = getToolDisplayMessage({
input: {},
toolName: 'create_complete_dashboard',
isFinished: true,
displayContext: emptyDisplayContext,
});
expect(message).toContain('Ran');
expect(message).toContain('create_complete_dashboard');
});
it('should use default Ran/Running for action tools without custom status labels', () => {
const displayContext = makeDisplayContext({
labels: [['http_request', 'HTTP Request']],
indexEntries: [['http_request', { category: ToolCategory.ACTION }]],
});
const finished = getToolDisplayMessage({
input: {},
toolName: 'http_request',
isFinished: true,
displayContext,
});
const inProgress = getToolDisplayMessage({
input: {},
toolName: 'http_request',
isFinished: false,
displayContext,
});
expect(finished).toContain('Ran');
expect(finished).toContain('HTTP Request');
expect(inProgress).toContain('Running');
expect(inProgress).toContain('HTTP Request');
});
});
describe('CRUD status labels', () => {
it('should build completed label from object metadata', () => {
const displayContext = makeDisplayContext({
labels: [['create_one_person', 'Create person']],
indexEntries: [
[
'create_one_person',
{ category: ToolCategory.DATABASE_CRUD, objectName: 'person' },
],
],
objectMetadataItems: [personMetadata],
});
const message = getToolDisplayMessage({
input: {},
toolName: 'create_one_person',
isFinished: true,
displayContext,
});
expect(message).toBe('Created person');
});
it('should build in-progress label from object metadata', () => {
const displayContext = makeDisplayContext({
labels: [['create_one_person', 'Create person']],
indexEntries: [
[
'create_one_person',
{ category: ToolCategory.DATABASE_CRUD, objectName: 'person' },
],
],
objectMetadataItems: [personMetadata],
});
const message = getToolDisplayMessage({
input: {},
toolName: 'create_one_person',
isFinished: false,
displayContext,
});
expect(message).toBe('Creating person');
});
it('should fall back to generic Ran/Running when object metadata is missing', () => {
const displayContext = makeDisplayContext({
labels: [['create_one_person', 'Create person']],
});
const finished = getToolDisplayMessage({
input: {},
toolName: 'create_one_person',
isFinished: true,
displayContext,
});
const inProgress = getToolDisplayMessage({
input: {},
toolName: 'create_one_person',
isFinished: false,
displayContext,
});
expect(finished).toBe('Ran Create person');
expect(inProgress).toBe('Running Create person');
});
});
describe('action status labels', () => {
it('should use custom action status labels when available', () => {
const displayContext = makeDisplayContext({
labels: [['send_email', 'Send Email']],
indexEntries: [['send_email', { category: ToolCategory.ACTION }]],
});
const finished = getToolDisplayMessage({
input: {},
toolName: 'send_email',
isFinished: true,
displayContext,
});
const inProgress = getToolDisplayMessage({
input: {},
toolName: 'send_email',
isFinished: false,
displayContext,
});
expect(finished).toBe('Sent email');
expect(inProgress).toBe('Sending email');
});
});
describe('execute_tool wrapper', () => {
it('should unwrap execute_tool and display inner tool label from map', () => {
const displayContext = makeDisplayContext({
labels: [['find_many_companies', 'Search Companies']],
});
const message = getToolDisplayMessage({
input: { toolName: 'find_many_companies', arguments: { limit: 10 } },
toolName: 'execute_tool',
isFinished: true,
displayContext,
});
expect(message).toBe('Ran Search Companies');
});
it('should unwrap execute_tool and fall back to tool name without label', () => {
const message = getToolDisplayMessage({
input: { toolName: 'find_many_companies', arguments: { limit: 10 } },
toolName: 'execute_tool',
isFinished: true,
displayContext: emptyDisplayContext,
});
expect(message).toBe('Ran find_many_companies');
});
it('should not recurse infinitely on nested execute_tool payloads', () => {
const displayContext = makeDisplayContext({
labels: [['execute_tool', 'Execute Tool']],
});
const message = getToolDisplayMessage({
input: {
toolName: 'execute_tool',
arguments: { toolName: 'inner', arguments: {} },
},
toolName: 'execute_tool',
isFinished: true,
displayContext,
});
expect(message).toContain('Ran');
expect(message).toContain('Execute Tool');
});
it('should unwrap execute_tool and use meta-tool handlers for web_search', () => {
const message = getToolDisplayMessage({
input: {
toolName: 'web_search',
arguments: { query: 'CRM tools' },
},
toolName: 'execute_tool',
isFinished: false,
displayContext: emptyDisplayContext,
});
expect(message).toBe('Searching the web for CRM tools');
});
it('should unwrap execute_tool and use meta-tool handlers for code_interpreter', () => {
const message = getToolDisplayMessage({
input: {
toolName: 'code_interpreter',
arguments: { loadingMessage: 'Analyzing spreadsheet' },
},
toolName: 'execute_tool',
isFinished: false,
displayContext: emptyDisplayContext,
});
expect(message).toBe('Analyzing spreadsheet');
});
it('should unwrap execute_tool and use meta-tool handlers for learn_tools', () => {
const displayContext = makeDisplayContext({
labels: [['send_email', 'Send Email']],
});
const message = getToolDisplayMessage({
input: {
toolName: 'learn_tools',
arguments: { toolNames: ['send_email'] },
},
toolName: 'execute_tool',
isFinished: true,
displayContext,
});
expect(message).toBe('Learned Send Email');
});
});
});
@@ -0,0 +1,22 @@
import { parseCrudToolName } from '@/ai/utils/tool-display/parse-crud-tool-name.util';
describe('parseCrudToolName', () => {
it('should parse create_one tools', () => {
expect(parseCrudToolName('create_one_company')).toEqual({
operation: 'create_one',
objectSlug: 'company',
});
});
it('should parse find_many tools', () => {
expect(parseCrudToolName('find_many_companies')).toEqual({
operation: 'find_many',
objectSlug: 'companies',
});
});
it('should return null for non-CRUD tools', () => {
expect(parseCrudToolName('send_email')).toBeNull();
expect(parseCrudToolName('web_search')).toBeNull();
});
});
@@ -0,0 +1,30 @@
import { i18n } from '@lingui/core';
import { isDefined } from 'twenty-shared/utils';
import { ACTION_TOOL_STATUS_LABELS } from '@/ai/constants/action-tool-status-labels.constant';
import { buildGenericToolStatusMessage } from '@/ai/utils/tool-display/build-generic-tool-status-message.util';
import { pickStatusLabel } from '@/ai/utils/tool-display/pick-status-label.util';
import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type';
export const buildActionToolStatusMessage = ({
toolName,
isFinished,
displayContext,
}: {
toolName: string;
isFinished: boolean;
displayContext: ToolDisplayContext;
}): string => {
const label = displayContext.labelByName.get(toolName) ?? toolName;
const statusLabels = ACTION_TOOL_STATUS_LABELS[toolName];
if (isDefined(statusLabels)) {
return pickStatusLabel({
isFinished,
loadingLabel: i18n._(statusLabels.loading),
completedLabel: i18n._(statusLabels.completed),
});
}
return buildGenericToolStatusMessage({ label, isFinished });
};
@@ -0,0 +1,43 @@
import { i18n } from '@lingui/core';
import { CRUD_TOOL_OPERATION_VERBS } from '@/ai/constants/crud-tool-operation-verbs.constant';
import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type';
import { getObjectLabelForCrudOperation } from '@/ai/utils/tool-display/get-object-label-for-crud-operation.util';
import { parseCrudToolName } from '@/ai/utils/tool-display/parse-crud-tool-name.util';
import { pickStatusLabel } from '@/ai/utils/tool-display/pick-status-label.util';
import { isDefined } from 'twenty-shared/utils';
export const buildCrudToolStatusMessage = ({
toolName,
isFinished,
displayContext,
}: {
toolName: string;
isFinished: boolean;
displayContext: ToolDisplayContext;
}): string | null => {
const parsedCrudToolName = parseCrudToolName(toolName);
if (!parsedCrudToolName) {
return null;
}
const indexEntry = displayContext.indexByName.get(toolName);
const objectLabel = getObjectLabelForCrudOperation({
operation: parsedCrudToolName.operation,
objectName: indexEntry?.objectName,
objectSlug: parsedCrudToolName.objectSlug,
objectMetadataItems: displayContext.objectMetadataItems,
});
const verbs = CRUD_TOOL_OPERATION_VERBS[parsedCrudToolName.operation];
if (!isDefined(objectLabel)) {
return null;
}
return pickStatusLabel({
isFinished,
loadingLabel: i18n._({ ...verbs.loading, values: { objectLabel } }),
completedLabel: i18n._({ ...verbs.completed, values: { objectLabel } }),
});
};
@@ -0,0 +1,16 @@
import { t } from '@lingui/core/macro';
import { pickStatusLabel } from '@/ai/utils/tool-display/pick-status-label.util';
export const buildGenericToolStatusMessage = ({
label,
isFinished,
}: {
label: string;
isFinished: boolean;
}): string =>
pickStatusLabel({
isFinished,
loadingLabel: t`Running ${label}`,
completedLabel: t`Ran ${label}`,
});
@@ -0,0 +1,48 @@
import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type';
import { formatDisplayList } from '@/ai/utils/tool-display/format-display-list.util';
import { getInnerToolName } from '@/ai/utils/tool-display/get-inner-tool-name.util';
import { pickStatusLabel } from '@/ai/utils/tool-display/pick-status-label.util';
export const buildNamedItemsStatusMessage = ({
names,
isFinished,
displayContext,
output,
loadingLabel,
completedLabel,
loadingFallback,
completedFallback,
}: {
names: string[];
isFinished: boolean;
displayContext: ToolDisplayContext;
output?: unknown;
loadingLabel: (formattedNames: string) => string;
completedLabel: (formattedNames: string) => string;
loadingFallback: string;
completedFallback: string;
}): string => {
if (names.length === 0) {
return pickStatusLabel({
isFinished,
loadingLabel: loadingFallback,
completedLabel: completedFallback,
});
}
const labels = names.map((name) =>
getInnerToolName({
toolName: name,
labelByName: displayContext.labelByName,
output,
}),
);
const formattedNames = formatDisplayList(labels);
return pickStatusLabel({
isFinished,
loadingLabel: loadingLabel(formattedNames),
completedLabel: completedLabel(formattedNames),
});
};
@@ -0,0 +1,48 @@
import { ToolCategory } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type';
import { buildActionToolStatusMessage } from '@/ai/utils/tool-display/build-action-tool-status-message.util';
import { buildCrudToolStatusMessage } from '@/ai/utils/tool-display/build-crud-tool-status-message.util';
import { buildGenericToolStatusMessage } from '@/ai/utils/tool-display/build-generic-tool-status-message.util';
import { parseCrudToolName } from '@/ai/utils/tool-display/parse-crud-tool-name.util';
export const buildToolStatusMessageByCategory = ({
toolName,
isFinished,
displayContext,
}: {
toolName: string;
isFinished: boolean;
displayContext: ToolDisplayContext;
}): string => {
const indexEntry = displayContext.indexByName.get(toolName);
const category = indexEntry?.category;
const isCrudTool =
category === ToolCategory.DATABASE_CRUD ||
isDefined(parseCrudToolName(toolName));
if (isCrudTool) {
const crudMessage = buildCrudToolStatusMessage({
toolName,
isFinished,
displayContext,
});
if (isDefined(crudMessage)) {
return crudMessage;
}
}
if (category === ToolCategory.ACTION) {
return buildActionToolStatusMessage({
toolName,
isFinished,
displayContext,
});
}
const label = displayContext.labelByName.get(toolName) ?? toolName;
return buildGenericToolStatusMessage({ label, isFinished });
};
@@ -0,0 +1,24 @@
import { z } from 'zod';
import { type ToolInput } from '@/ai/types/ToolInput';
const DirectQuerySchema = z.object({ query: z.string() });
const NestedQuerySchema = z.object({
action: z.object({ query: z.string() }),
});
export const extractSearchQuery = (input: ToolInput): string | null => {
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 null;
};
@@ -0,0 +1,7 @@
import { i18n } from '@lingui/core';
export const formatDisplayList = (items: string[]): string =>
new Intl.ListFormat(i18n.locale, {
style: 'long',
type: 'conjunction',
}).format(items);
@@ -0,0 +1,24 @@
import { getToolOutputLabelEntries } from '@/ai/utils/getToolOutputLabelEntries';
import { isDefined } from 'twenty-shared/utils';
export const getInnerToolName = ({
toolName,
labelByName,
output,
}: {
toolName: string;
labelByName: Map<string, string>;
output?: unknown;
}): string => {
const indexLabel = labelByName.get(toolName);
if (isDefined(indexLabel)) {
return indexLabel;
}
const outputEntry = getToolOutputLabelEntries(output).find(
(entry) => entry.name === toolName,
);
return outputEntry?.label ?? toolName;
};
@@ -0,0 +1,38 @@
import { i18n } from '@lingui/core';
import { type CrudToolOperation } from '@/ai/constants/crud-tool-operation-verbs.constant';
import { isCrudPluralOperation } from '@/ai/utils/tool-display/is-crud-plural-operation.util';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { isDefined } from 'twenty-shared/utils';
export const getObjectLabelForCrudOperation = ({
operation,
objectName,
objectSlug,
objectMetadataItems,
}: {
operation: CrudToolOperation;
objectName?: string | null;
objectSlug: string;
objectMetadataItems: EnrichedObjectMetadataItem[];
}): string | undefined => {
const objectMetadata = isDefined(objectName)
? objectMetadataItems.find(
(metadataItem) => metadataItem.nameSingular === objectName,
)
: objectMetadataItems.find(
(metadataItem) =>
metadataItem.nameSingular === objectSlug ||
metadataItem.namePlural === objectSlug,
);
if (!isDefined(objectMetadata)) {
return undefined;
}
const objectLabel = isCrudPluralOperation(operation)
? objectMetadata.labelPlural
: objectMetadata.labelSingular;
return objectLabel.toLocaleLowerCase(i18n.locale);
};
@@ -0,0 +1,135 @@
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { z } from 'zod';
import { type ToolDisplayContext } from '@/ai/types/tool-display-context.type';
import { type ToolInput } from '@/ai/types/ToolInput';
import { buildNamedItemsStatusMessage } from '@/ai/utils/tool-display/build-named-items-status-message.util';
import { buildToolStatusMessageByCategory } from '@/ai/utils/tool-display/build-tool-status-message-by-category.util';
import { extractSearchQuery } from '@/ai/utils/tool-display/extract-search-query.util';
import { pickStatusLabel } from '@/ai/utils/tool-display/pick-status-label.util';
import { unwrapToolInput } from '@/ai/utils/tool-display/unwrap-tool-input.util';
const ModelGeneratedLabelSchema = z.object({
loadingMessage: z.string(),
completedMessage: z.string().optional(),
});
const LearnToolsSchema = z.object({ toolNames: z.array(z.string()) });
const LoadSkillsSchema = z.object({ skillNames: z.array(z.string()) });
export const getToolDisplayMessage = ({
input,
toolName,
isFinished,
displayContext,
output,
}: {
input: ToolInput;
toolName: string;
isFinished: boolean;
displayContext: ToolDisplayContext;
output?: unknown;
}): string => {
const { toolInput, toolName: resolvedToolName } = unwrapToolInput({
input,
toolName,
});
return buildToolDisplayMessage({
input: toolInput,
toolName: resolvedToolName,
isFinished,
displayContext,
output,
});
};
const buildToolDisplayMessage = ({
input,
toolName,
isFinished,
displayContext,
output,
}: {
input: ToolInput;
toolName: string;
isFinished: boolean;
displayContext: ToolDisplayContext;
output?: unknown;
}): string => {
switch (toolName) {
case 'web_search':
case 'app_exa_web_search': {
const query = extractSearchQuery(input);
if (isNonEmptyString(query)) {
return pickStatusLabel({
isFinished,
completedLabel: t`Searched the web for ${query}`,
loadingLabel: t`Searching the web for ${query}`,
});
}
return pickStatusLabel({
isFinished,
completedLabel: t`Searched the web`,
loadingLabel: t`Searching the web`,
});
}
case 'learn_tools': {
const parsed = LearnToolsSchema.safeParse(input);
return buildNamedItemsStatusMessage({
names: parsed.success ? parsed.data.toolNames : [],
isFinished,
displayContext,
output,
loadingLabel: (formattedNames) => t`Learning ${formattedNames}`,
completedLabel: (formattedNames) => t`Learned ${formattedNames}`,
loadingFallback: t`Learning tools...`,
completedFallback: t`Learned tools`,
});
}
case 'load_skills': {
const parsed = LoadSkillsSchema.safeParse(input);
return buildNamedItemsStatusMessage({
names: parsed.success ? parsed.data.skillNames : [],
isFinished,
displayContext,
output,
loadingLabel: (formattedNames) => t`Loading ${formattedNames}`,
completedLabel: (formattedNames) => t`Loaded ${formattedNames}`,
loadingFallback: t`Loading skills...`,
completedFallback: t`Loaded skills`,
});
}
case 'code_interpreter': {
const parsed = ModelGeneratedLabelSchema.safeParse(input);
if (parsed.success && isNonEmptyString(parsed.data.loadingMessage)) {
const completedMessage = isNonEmptyString(parsed.data.completedMessage)
? parsed.data.completedMessage
: parsed.data.loadingMessage;
return pickStatusLabel({
isFinished,
completedLabel: completedMessage,
loadingLabel: parsed.data.loadingMessage,
});
}
return pickStatusLabel({
isFinished,
completedLabel: t`Ran code`,
loadingLabel: t`Running code`,
});
}
default:
return buildToolStatusMessageByCategory({
toolName,
isFinished,
displayContext,
});
}
};
@@ -0,0 +1,4 @@
import { type CrudToolOperation } from '@/ai/constants/crud-tool-operation-verbs.constant';
export const isCrudPluralOperation = (operation: CrudToolOperation): boolean =>
operation.endsWith('_many') || operation === 'group_by';
@@ -0,0 +1,22 @@
import { type CrudToolOperation } from '@/ai/constants/crud-tool-operation-verbs.constant';
import { DATABASE_CRUD_OPERATIONS } from 'twenty-shared/ai';
export const parseCrudToolName = (
toolName: string,
): {
operation: CrudToolOperation;
objectSlug: string;
} | null => {
for (const operation of DATABASE_CRUD_OPERATIONS) {
const prefix = `${operation}_`;
if (toolName.startsWith(prefix)) {
return {
operation,
objectSlug: toolName.slice(prefix.length),
};
}
}
return null;
};
@@ -0,0 +1,9 @@
export const pickStatusLabel = ({
isFinished,
loadingLabel,
completedLabel,
}: {
isFinished: boolean;
loadingLabel: string;
completedLabel: string;
}): string => (isFinished ? completedLabel : loadingLabel);
@@ -0,0 +1,34 @@
import { z } from 'zod';
import { type ToolInput } from '@/ai/types/ToolInput';
const ExecuteToolSchema = z.object({
toolName: z.coerce.string(),
arguments: z.unknown(),
});
export const unwrapToolInput = ({
input,
toolName,
}: {
input: ToolInput;
toolName: string;
}): {
toolInput: ToolInput;
toolName: string;
} => {
if (toolName !== 'execute_tool') {
return { toolInput: input, toolName };
}
const parsed = ExecuteToolSchema.safeParse(input);
if (!parsed.success) {
return { toolInput: input, toolName };
}
return {
toolInput: parsed.data.arguments as ToolInput,
toolName: parsed.data.toolName,
};
};
@@ -2,7 +2,8 @@ import { styled } from '@linaria/react';
import { useContext, useState } from 'react';
import { type AiToolCallLog } from 'twenty-shared/workflow';
import { getToolDisplayMessage } from '@/ai/utils/getToolDisplayMessage';
import { useToolDisplayContext } from '@/ai/hooks/useToolDisplayContext';
import { getToolDisplayMessage } from '@/ai/utils/tool-display/get-tool-display-message';
import { getToolIcon } from '@/ai/utils/getToolIcon';
import { useLingui } from '@lingui/react/macro';
import { type JsonValue } from 'type-fest';
@@ -160,11 +161,14 @@ export const WorkflowRunStepLogsToolCallRow = ({
? themeCssVariables.color.red
: themeCssVariables.color.green;
const displayMessage = getToolDisplayMessage(
toolCall.input ?? {},
toolCall.toolName,
true,
);
const displayContext = useToolDisplayContext();
const displayMessage = getToolDisplayMessage({
input: toolCall.input ?? {},
toolName: toolCall.toolName,
isFinished: true,
displayContext,
output: toolCall.output,
});
return (
<StyledContainer>
@@ -87,7 +87,9 @@ export const SettingsToolDetail = () => {
const isReadOnly = !isCustomTool || isManaged;
const name = isCustomTool ? logicFunction?.name : toolIdentifier;
const displayName = isCustomTool
? logicFunction?.name
: (systemTool?.label ?? toolIdentifier);
const description = isCustomTool
? logicFunction?.description
: systemTool?.description;
@@ -122,9 +124,9 @@ export const SettingsToolDetail = () => {
}
}, 1_000);
const handleNameChange = (value: string) => {
setEditedName(value);
debouncedSaveName(value);
const handleNameChange = (newName: string) => {
setEditedName(newName);
debouncedSaveName(newName);
};
const debouncedSaveDescription = useDebouncedCallback(
@@ -174,11 +176,11 @@ export const SettingsToolDetail = () => {
title={
isCustomTool ? (
<SettingsLogicFunctionLabelContainer
value={editedName ?? name ?? ''}
value={editedName ?? displayName ?? ''}
onChange={handleNameChange}
/>
) : (
(name ?? '')
(displayName ?? '')
)
}
links={[
@@ -190,7 +192,7 @@ export const SettingsToolDetail = () => {
children: t`AI`,
href: getSettingsPath(SettingsPath.AI, undefined, undefined, 'tools'),
},
{ children: editedName ?? name ?? '' },
{ children: editedName ?? displayName ?? '' },
]}
>
<SettingsPageContainer>
@@ -50,7 +50,9 @@ export const SettingsAgentToolsTab = () => {
const searchNormalized = normalizeSearchText(searchTerm);
const matchesSearch =
normalizeSearchText(tool.name).includes(searchNormalized) ||
normalizeSearchText(tool.label ?? tool.name).includes(
searchNormalized,
) ||
normalizeSearchText(tool.description ?? '').includes(searchNormalized);
if (!matchesSearch) {
@@ -67,7 +69,7 @@ export const SettingsAgentToolsTab = () => {
return showCustomTools;
})
.sort((a, b) => a.name.localeCompare(b.name));
.sort((a, b) => (a.label ?? a.name).localeCompare(b.label ?? b.name));
return (
<Section>
@@ -88,7 +88,7 @@ export const SettingsAgentToolsTable = ({
marketplaceApp={marketplaceApp}
/>
}
name={tool.name}
name={tool.label ?? tool.name}
applicationId={getToolApplicationId(tool, currentWorkspace)}
action={
<IconChevronRight
@@ -45,6 +45,7 @@ export const useSettingsAgentToolsTable = () => {
.map((tool) => ({
identifier: tool.name,
name: tool.name,
label: tool.label,
description: tool.description,
category: tool.category,
objectName: tool.objectName,
@@ -1,6 +1,7 @@
export type SettingsAgentToolItem = {
identifier: string;
name: string;
label?: string;
description?: string | null;
category?: string;
objectName?: string | null;
@@ -164,7 +164,6 @@ export class McpProtocolService {
const preloadedTools = await this.toolRegistry.getToolsByName(
COMMON_PRELOAD_TOOLS,
toolContext,
{ includeLoadingMessage: false },
);
return {
@@ -0,0 +1,36 @@
import { msg } from '@lingui/core/macro';
import { type ActionToolLabel } from 'src/engine/core-modules/tool-provider/types/action-tool-label.type';
import { i18nLabel } from 'src/engine/workspace-manager/twenty-standard-application/utils/i18n-label.util';
export const ACTION_TOOL_IDS = [
'http_request',
'send_email',
'draft_email',
'search_help_center',
'code_interpreter',
'navigate_app',
] as const;
export type ActionToolId = (typeof ACTION_TOOL_IDS)[number];
export const ACTION_TOOL_LABELS: Record<ActionToolId, ActionToolLabel> = {
http_request: {
label: i18nLabel(msg`HTTP Request`),
},
send_email: {
label: i18nLabel(msg`Send Email`),
},
draft_email: {
label: i18nLabel(msg`Draft Email`),
},
search_help_center: {
label: i18nLabel(msg`Search Help Center`),
},
code_interpreter: {
label: i18nLabel(msg`Code Interpreter`),
},
navigate_app: {
label: i18nLabel(msg`Navigate App`),
},
};
@@ -1,14 +1,4 @@
export const DATABASE_CRUD_OPERATIONS = [
'find_many',
'find_one',
'create_one',
'create_many',
'update_one',
'update_many',
'upsert_many',
'delete_one',
'delete_many',
'group_by',
] as const;
export type DatabaseCrudOperation = (typeof DATABASE_CRUD_OPERATIONS)[number];
export {
DATABASE_CRUD_OPERATIONS,
type DatabaseCrudOperation,
} from 'twenty-shared/ai';
@@ -1,4 +1,5 @@
import { type ActorMetadata } from 'twenty-shared/types';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type';
@@ -13,5 +14,6 @@ export type ToolProviderContext = {
userId?: string;
userWorkspaceId?: string;
threadId?: string;
locale?: keyof typeof APP_LOCALES;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
@@ -4,7 +4,6 @@ export type ToolRetrievalOptions = {
categories?: ToolCategory[];
excludeTools?: string[];
wrapWithErrorContext?: boolean;
includeLoadingMessage?: boolean;
// Apply output compaction (strip nulls/empty values) to dispatch results
// before returning. Chat enables this to reduce token usage in the
// conversation context; MCP and workflow agents leave raw output intact.
@@ -1,7 +1,9 @@
import { type ObjectPermissions } from 'twenty-shared/types';
import { type I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
@@ -34,7 +36,7 @@ const createFlatObject = (
});
describe('DatabaseToolProvider', () => {
const generateDescriptorNames = async (objects: FlatObjectMetadata[]) => {
const generateDescriptors = async (objects: FlatObjectMetadata[]) => {
const flatObjectMetadataMaps =
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatObjectMetadata>;
@@ -62,19 +64,39 @@ describe('DatabaseToolProvider', () => {
}),
} as unknown as WorkspaceManyOrAllFlatEntityMapsCacheService;
// Returns the messageId so the label util falls back to the English source,
// mirroring the runtime behavior when no translation exists for the locale.
// getI18nInstance resolves verb descriptors to their English source message.
const i18nService = {
translateMessage: jest.fn(
({ messageId }: { messageId: string }) => messageId,
),
getI18nInstance: jest.fn(() => ({
_: (descriptor: string | { id: string; message?: string }) =>
typeof descriptor === 'string'
? descriptor
: (descriptor.message ?? descriptor.id),
})),
} as unknown as I18nService;
const provider = new DatabaseToolProvider(
workspaceCacheService,
flatEntityMapsCacheService,
i18nService,
);
const descriptors = (await provider.generateDescriptors(
return (await provider.generateDescriptors(
{
workspaceId,
roleId,
rolePermissionConfig: { unionOf: [roleId] },
},
{ includeSchemas: false },
)) as ToolDescriptor[];
)) as (ToolIndexEntry | ToolDescriptor)[];
};
const generateDescriptorNames = async (objects: FlatObjectMetadata[]) => {
const descriptors = await generateDescriptors(objects);
return descriptors.map((descriptor) => descriptor.name);
};
@@ -171,4 +193,62 @@ describe('DatabaseToolProvider', () => {
]),
);
});
it('generates labels from operation verb and object metadata labels', async () => {
const descriptors = await generateDescriptors([
createFlatObject({
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
}),
]);
const labelByName = new Map(descriptors.map((d) => [d.name, d.label]));
expect(labelByName.get('find_many_companies')).toBe('Search companies');
expect(labelByName.get('find_one_company')).toBe('Find company');
expect(labelByName.get('group_by_companies')).toBe('Group companies');
expect(labelByName.get('create_one_company')).toBe('Create company');
expect(labelByName.get('create_many_companies')).toBe('Create companies');
expect(labelByName.get('update_one_company')).toBe('Update company');
expect(labelByName.get('update_many_companies')).toBe('Update companies');
expect(labelByName.get('upsert_many_companies')).toBe('Upsert companies');
expect(labelByName.get('delete_one_company')).toBe('Delete company');
expect(labelByName.get('delete_many_companies')).toBe('Delete companies');
});
it('uses the object labelSingular/labelPlural from metadata, not the programmatic name', async () => {
const descriptors = await generateDescriptors([
createFlatObject({
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Contact',
labelPlural: 'Contacts',
}),
]);
const labelByName = new Map(descriptors.map((d) => [d.name, d.label]));
expect(labelByName.get('find_many_people')).toBe('Search contacts');
expect(labelByName.get('find_one_person')).toBe('Find contact');
expect(labelByName.get('create_one_person')).toBe('Create contact');
expect(labelByName.get('delete_one_person')).toBe('Delete contact');
});
it('includes label on every generated descriptor', async () => {
const descriptors = await generateDescriptors([
createFlatObject({
nameSingular: 'task',
namePlural: 'tasks',
labelSingular: 'Task',
labelPlural: 'Tasks',
}),
]);
for (const descriptor of descriptors) {
expect(descriptor.label).toBeDefined();
expect(descriptor.label.length).toBeGreaterThan(0);
}
});
});
@@ -1,11 +1,20 @@
import { Injectable } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import {
ACTION_TOOL_LABELS,
type ActionToolId,
} from 'src/engine/core-modules/tool-provider/constants/action-tool-label.constant';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ActionToolLabel } from 'src/engine/core-modules/tool-provider/types/action-tool-label.type';
import { translateToolLabel } from 'src/engine/core-modules/tool-provider/utils/translate-tool-label.util';
import { humanizeToolName } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { ToolCategory } from 'twenty-shared/ai';
import { toToolJsonSchema } from 'src/engine/core-modules/record-crud/utils/to-tool-json-schema.util';
@@ -41,6 +50,7 @@ export class ActionToolProvider implements ToolProvider {
private readonly searchOutputTool: SearchOutputTool,
private readonly codeInterpreterService: CodeInterpreterService,
private readonly permissionsService: PermissionsService,
private readonly i18nService: I18nService,
) {
this.toolMap = new Map<string, Tool>([
['http_request', this.httpTool],
@@ -73,7 +83,12 @@ export class ActionToolProvider implements ToolProvider {
if (hasHttpPermission) {
descriptors.push(
this.buildDescriptor('http_request', this.httpTool, includeSchemas),
this.buildDescriptor(
'http_request',
this.httpTool,
includeSchemas,
context.locale,
),
);
}
@@ -85,13 +100,19 @@ export class ActionToolProvider implements ToolProvider {
if (hasEmailPermission) {
descriptors.push(
this.buildDescriptor('send_email', this.sendEmailTool, includeSchemas),
this.buildDescriptor(
'send_email',
this.sendEmailTool,
includeSchemas,
context.locale,
),
);
descriptors.push(
this.buildDescriptor(
'draft_email',
this.draftEmailTool,
includeSchemas,
context.locale,
),
);
}
@@ -101,6 +122,7 @@ export class ActionToolProvider implements ToolProvider {
'search_help_center',
this.searchHelpCenterTool,
includeSchemas,
context.locale,
),
);
@@ -109,6 +131,7 @@ export class ActionToolProvider implements ToolProvider {
'navigate_app',
this.navigateAppTool,
includeSchemas,
context.locale,
),
);
@@ -142,6 +165,7 @@ export class ActionToolProvider implements ToolProvider {
'code_interpreter',
this.codeInterpreterTool,
includeSchemas,
context.locale,
),
);
}
@@ -175,9 +199,16 @@ export class ActionToolProvider implements ToolProvider {
toolId: string,
tool: Tool,
includeSchemas: boolean,
locale?: ToolProviderContext['locale'],
): ToolIndexEntry | ToolDescriptor {
const labels: ActionToolLabel | undefined =
ACTION_TOOL_LABELS[toolId as ActionToolId];
return {
name: toolId,
label: isDefined(labels)
? translateToolLabel(labels.label, this.i18nService, locale)
: humanizeToolName(toolId),
description: tool.description,
category: ToolCategory.ACTION,
icon: 'IconPlayerPlay',
@@ -7,9 +7,11 @@ import {
import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { getCrudToolLabels } from 'src/engine/core-modules/tool-provider/utils/get-crud-tool-label.util';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { generateCreateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util';
@@ -41,6 +43,7 @@ export class DatabaseToolProvider implements ToolProvider {
constructor(
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly i18nService: I18nService,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
@@ -129,6 +132,12 @@ export class DatabaseToolProvider implements ToolProvider {
if (permission.canReadObjectRecords) {
descriptors.push({
name: `find_many_${snakePlural}`,
...getCrudToolLabels(
'find_many',
flatObject.labelPlural,
this.i18nService,
context.locale,
),
description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. Filter fields are top-level arguments — pass each field as its own key (e.g. { id: { eq: "record-id" } }, or { name: { firstName: { ilike: "%ada%" } } }); do NOT wrap them in a "filter" object and do NOT place a bare operator like "ilike"/"eq" at the top level. Combine conditions with and/or/not. Returns an array of matching records with their full data.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`find_many_${snakePlural}`) && {
@@ -148,6 +157,12 @@ export class DatabaseToolProvider implements ToolProvider {
descriptors.push({
name: `find_one_${snakeSingular}`,
...getCrudToolLabels(
'find_one',
flatObject.labelSingular,
this.i18nService,
context.locale,
),
description: `Retrieve a single ${objectMetadata.labelSingular} by ID.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`find_one_${snakeSingular}`) && {
@@ -177,6 +192,12 @@ export class DatabaseToolProvider implements ToolProvider {
if (hasGroupBySchema) {
descriptors.push({
name: groupByName,
...getCrudToolLabels(
'group_by',
flatObject.labelPlural,
this.i18nService,
context.locale,
),
description: `Group ${objectMetadata.labelPlural} records by one or two fields and compute an aggregate (COUNT, SUM, AVG, MIN, MAX, etc.). Use for questions like "how many deals per stage?" or "total revenue by company". Returns groups with dimension values and aggregate results, ordered by the aggregate value.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldGenerateGroupBy &&
@@ -198,6 +219,12 @@ export class DatabaseToolProvider implements ToolProvider {
if (permission.canUpdateObjectRecords && canBeManagedByAutomation) {
descriptors.push({
name: `create_one_${snakeSingular}`,
...getCrudToolLabels(
'create_one',
flatObject.labelSingular,
this.i18nService,
context.locale,
),
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`create_one_${snakeSingular}`) && {
@@ -217,6 +244,12 @@ export class DatabaseToolProvider implements ToolProvider {
descriptors.push({
name: `create_many_${snakePlural}`,
...getCrudToolLabels(
'create_many',
flatObject.labelPlural,
this.i18nService,
context.locale,
),
description: `Create multiple ${objectMetadata.labelPlural} records in a single call. Provide an array of records, each containing the required fields. Maximum 20 records per call. Returns the created records.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`create_many_${snakePlural}`) && {
@@ -239,6 +272,12 @@ export class DatabaseToolProvider implements ToolProvider {
descriptors.push({
name: `update_one_${snakeSingular}`,
...getCrudToolLabels(
'update_one',
flatObject.labelSingular,
this.i18nService,
context.locale,
),
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`update_one_${snakeSingular}`) && {
@@ -258,6 +297,12 @@ export class DatabaseToolProvider implements ToolProvider {
descriptors.push({
name: `update_many_${snakePlural}`,
...getCrudToolLabels(
'update_many',
flatObject.labelPlural,
this.i18nService,
context.locale,
),
description: `Apply the SAME field values to all ${objectMetadata.labelPlural} records matching a filter. Use when every matched record gets identical changes (e.g. bulk status change). For records that each have different data to update, use upsert_many_${snakePlural} instead. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`update_many_${snakePlural}`) && {
@@ -280,6 +325,12 @@ export class DatabaseToolProvider implements ToolProvider {
descriptors.push({
name: `upsert_many_${snakePlural}`,
...getCrudToolLabels(
'upsert_many',
flatObject.labelPlural,
this.i18nService,
context.locale,
),
description: `Insert or update multiple ${objectMetadata.labelPlural} records in a single call, where each record has its own individual data. Use this instead of update_many_${snakePlural} when records need different field values. Existing records are matched by unique fields and updated; records with no match are created. Maximum 20 records per call. Returns the upserted records.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`upsert_many_${snakePlural}`) && {
@@ -304,6 +355,12 @@ export class DatabaseToolProvider implements ToolProvider {
if (permission.canSoftDeleteObjectRecords) {
descriptors.push({
name: `delete_one_${snakeSingular}`,
...getCrudToolLabels(
'delete_one',
flatObject.labelSingular,
this.i18nService,
context.locale,
),
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,
category: ToolCategory.DATABASE_CRUD,
...(includeSchemas && {
@@ -321,6 +378,12 @@ export class DatabaseToolProvider implements ToolProvider {
descriptors.push({
name: `delete_many_${snakePlural}`,
...getCrudToolLabels(
'delete_many',
flatObject.labelPlural,
this.i18nService,
context.locale,
),
description: `Soft-delete multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. Deleted records are hidden from normal queries and the operation is reversible. WARNING: Use specific filters to avoid unintended mass deletions.`,
category: ToolCategory.DATABASE_CRUD,
...(includeSchemas && {
@@ -77,6 +77,7 @@ export class LogicFunctionToolProvider implements ToolProvider {
const base: ToolIndexEntry = {
name: toolName,
label: logicFunction.name,
description:
logicFunction.description ||
`Execute the ${logicFunction.name} logic function`,
@@ -2,6 +2,7 @@ import { UseGuards } from '@nestjs/common';
import { Args, Field, ObjectType, Query } from '@nestjs/graphql';
import graphqlTypeJson from 'graphql-type-json';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
@@ -10,6 +11,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { RequestLocale } from 'src/engine/decorators/locale/request-locale.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -19,6 +21,9 @@ export class ToolIndexEntryDTO {
@Field()
name: string;
@Field()
label: string;
@Field()
description: string;
@@ -49,6 +54,7 @@ export class ToolIndexResolver {
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
@RequestLocale() locale: keyof typeof APP_LOCALES | undefined,
): Promise<ToolIndexEntryDTO[]> {
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId,
@@ -62,6 +68,7 @@ export class ToolIndexResolver {
return this.toolRegistryService.buildToolIndex(workspace.id, roleId, {
userId: user?.id,
userWorkspaceId,
locale,
});
}
@@ -1,6 +1,7 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ToolSet, jsonSchema } from 'ai';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@@ -9,18 +10,14 @@ import { type ToolRetrievalOptions } from 'src/engine/core-modules/tool-provider
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-transforms/compact-tool-output.util';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service';
import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { findSimilarToolNames } from 'src/engine/core-modules/tool-provider/utils/find-similar-tool-names.util';
import { wrapWithErrorHandler } from 'src/engine/core-modules/tool-provider/utils/tool-error.util';
import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
stripLoadingMessage,
wrapJsonSchemaForExecution,
} from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@Injectable()
@@ -110,32 +107,23 @@ export class ToolRegistryService {
context: ToolProviderContext,
options?: {
wrapWithErrorContext?: boolean;
includeLoadingMessage?: boolean;
compactOutput?: boolean;
spillLargeOutput?: boolean;
},
): ToolSet {
const toolSet: ToolSet = {};
const includeLoadingMessage = options?.includeLoadingMessage ?? true;
const compactOutput = options?.compactOutput ?? false;
const spillLargeOutput = options?.spillLargeOutput ?? false;
for (const descriptor of descriptors) {
const baseSchema = descriptor.inputSchema as Record<string, unknown>;
const schema = includeLoadingMessage
? wrapJsonSchemaForExecution(baseSchema)
: baseSchema;
const schema = descriptor.inputSchema as Record<string, unknown>;
const executeFn = async (
args: Record<string, unknown>,
): Promise<ToolOutput> => {
const cleanArgs = includeLoadingMessage
? stripLoadingMessage(args ?? {})
: (args ?? {});
const result = await this.toolExecutorService.dispatch(
descriptor,
cleanArgs,
args,
context,
);
@@ -167,13 +155,18 @@ export class ToolRegistryService {
async buildToolIndex(
workspaceId: string,
roleId: string,
options?: { userId?: string; userWorkspaceId?: string },
options?: {
userId?: string;
userWorkspaceId?: string;
locale?: keyof typeof APP_LOCALES;
},
): Promise<ToolIndexEntry[]> {
const context = this.buildContextFromToolContext({
workspaceId,
roleId,
userId: options?.userId,
userWorkspaceId: options?.userWorkspaceId,
locale: options?.locale,
});
return this.getCatalog(context);
@@ -183,7 +176,6 @@ export class ToolRegistryService {
names: string[],
context: ToolContext,
options?: {
includeLoadingMessage?: boolean;
compactOutput?: boolean;
spillLargeOutput?: boolean;
},
@@ -208,7 +200,6 @@ export class ToolRegistryService {
}));
return this.hydrateToolSet(descriptors, fullContext, {
includeLoadingMessage: options?.includeLoadingMessage,
compactOutput: options?.compactOutput,
spillLargeOutput: options?.spillLargeOutput,
});
@@ -219,7 +210,11 @@ export class ToolRegistryService {
context: ToolContext,
aspects: LearnToolsAspect[] = ['description', 'schema'],
): Promise<
Array<{ name: string; description?: string; inputSchema?: object }>
Array<{
name: string;
description?: string;
inputSchema?: object;
}>
> {
const fullContext = this.buildContextFromToolContext(context);
@@ -351,7 +346,6 @@ export class ToolRegistryService {
categories,
excludeTools,
wrapWithErrorContext,
includeLoadingMessage,
compactOutput,
spillLargeOutput,
} = options;
@@ -387,7 +381,6 @@ export class ToolRegistryService {
const toolSet = this.hydrateToolSet(filteredDescriptors, context, {
wrapWithErrorContext,
includeLoadingMessage,
compactOutput,
spillLargeOutput,
});
@@ -414,6 +407,7 @@ export class ToolRegistryService {
userId: context.userId,
userWorkspaceId: context.userWorkspaceId,
threadId: context.threadId,
locale: context.locale,
onCodeExecutionUpdate: context.onCodeExecutionUpdate,
};
}
@@ -0,0 +1,3 @@
export type ActionToolLabel = {
label: string;
};
@@ -1,4 +1,5 @@
import { type ActorMetadata } from 'twenty-shared/types';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type';
@@ -12,5 +13,6 @@ export type ToolContext = {
userId?: string;
userWorkspaceId?: string;
threadId?: string;
locale?: keyof typeof APP_LOCALES;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
@@ -4,6 +4,7 @@ import { type ToolExecutionRef } from 'src/engine/core-modules/tool-provider/typ
export type ToolIndexEntry = {
name: string;
label: string;
description: string;
category: ToolCategory;
executionRef: ToolExecutionRef;
@@ -0,0 +1,59 @@
import { type ToolSet } from 'ai';
import { z } from 'zod';
import { ToolCategory } from 'twenty-shared/ai';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
const createMockToolSet = (
tools: Record<string, { description?: string; inputSchema?: z.ZodType }>,
): ToolSet => {
const toolSet: ToolSet = {};
for (const [name, def] of Object.entries(tools)) {
toolSet[name] = {
description: def.description,
inputSchema: def.inputSchema ?? z.object({}),
execute: async () => ({}),
};
}
return toolSet;
};
describe('toolSetToDescriptors', () => {
it('generates a humanized label when no labels map is provided', () => {
const toolSet = createMockToolSet({
create_complete_workflow: { description: 'Create a workflow' },
get_object_metadata: { description: 'Get object metadata' },
});
const descriptors = toolSetToDescriptors(toolSet, ToolCategory.WORKFLOW, {
includeSchemas: false,
});
const labelByName = new Map(descriptors.map((d) => [d.name, d.label]));
expect(labelByName.get('create_complete_workflow')).toBe(
'Create Complete Workflow',
);
expect(labelByName.get('get_object_metadata')).toBe('Get Object Metadata');
});
it('includes label on every descriptor', () => {
const toolSet = createMockToolSet({
tool_a: { description: 'A' },
tool_b: { description: 'B' },
tool_c: { description: 'C' },
});
const descriptors = toolSetToDescriptors(toolSet, ToolCategory.ACTION, {
includeSchemas: false,
});
for (const descriptor of descriptors) {
expect(descriptor.label).toBeDefined();
expect(descriptor.label.length).toBeGreaterThan(0);
}
});
});
@@ -7,10 +7,6 @@ import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.
// whose tools are produced as opaque AI-SDK ToolSet closures (view, metadata,
// workflow, dashboard, view-field) and which therefore cannot dispatch by
// executionRef alone.
//
// The factory closures expect a `loadingMessage` field (added by the chat UX
// wrapper) and a ToolExecutionOptions object; neither is meaningful when the
// executor is invoking them internally, so we pass empty defaults.
export const executeToolFromToolSet = async (
toolSet: ToolSet,
toolName: string,
@@ -25,8 +21,8 @@ export const executeToolFromToolSet = async (
);
}
return tool.execute(
{ loadingMessage: '', ...args },
{ toolCallId: '', messages: [] },
) as Promise<ToolOutput>;
return tool.execute(args, {
toolCallId: '',
messages: [],
}) as Promise<ToolOutput>;
};
@@ -0,0 +1,44 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { type I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type DatabaseCrudOperation } from 'src/engine/core-modules/tool-provider/constants/database-crud-operation.const';
import { translateToolLabel } from 'src/engine/core-modules/tool-provider/utils/translate-tool-label.util';
const OPERATION_VERBS: Record<DatabaseCrudOperation, MessageDescriptor> = {
find_many: msg`Search`,
find_one: msg`Find`,
group_by: msg`Group`,
create_one: msg`Create`,
create_many: msg`Create`,
update_one: msg`Update`,
update_many: msg`Update`,
upsert_many: msg`Upsert`,
delete_one: msg`Delete`,
delete_many: msg`Delete`,
};
type CrudToolLabel = {
label: string;
};
export const getCrudToolLabels = (
operation: DatabaseCrudOperation,
objectLabel: string,
i18nService: I18nService,
locale?: keyof typeof APP_LOCALES,
): CrudToolLabel => {
const i18n = i18nService.getI18nInstance(locale ?? SOURCE_LOCALE);
const verb = OPERATION_VERBS[operation];
const object = translateToolLabel(
objectLabel,
i18nService,
locale,
).toLocaleLowerCase(locale);
return {
label: `${i18n._(verb)} ${object}`,
};
};
@@ -11,9 +11,13 @@ export type ToolSetToDescriptorsOptions = {
icon?: string;
};
// Converts a ToolSet (with Zod schemas and closures) into an array of
// serializable ToolDescriptor objects. Used by providers that delegate to
// existing factory services (workflow, view, dashboard, metadata).
export const humanizeToolName = (name: string): string =>
name
.split('_')
.filter((word) => word.length > 0)
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
.join(' ');
export const toolSetToDescriptors = (
toolSet: ToolSet,
category: ToolCategory,
@@ -24,6 +28,7 @@ export const toolSetToDescriptors = (
return Object.entries(toolSet).map(([name, tool]) => {
const base: ToolIndexEntry = {
name,
label: humanizeToolName(name),
description: tool.description ?? '',
category,
executionRef: { kind: 'static' as const, toolId: name },
@@ -0,0 +1,22 @@
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { type I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
export const translateToolLabel = (
source: string,
i18nService: I18nService,
locale?: keyof typeof APP_LOCALES,
): string => {
if (source.length === 0) {
return source;
}
const messageId = generateMessageId(source);
const translated = i18nService.translateMessage({
messageId,
locale: locale ?? SOURCE_LOCALE,
});
return translated === messageId ? source : translated;
};
@@ -13,4 +13,15 @@ export const CodeInterpreterInputZodSchema = z.object({
)
.optional()
.describe('Files to make available in the execution environment'),
loadingMessage: z
.string()
.describe(
"A brief, present-tense status message shown to the user while the code runs (e.g., 'Analyzing sales data').",
),
completedMessage: z
.string()
.optional()
.describe(
"A brief, past-tense status message shown to the user after the code finishes (e.g., 'Analyzed sales data'). No exclamation marks. Don't be optimistic, stay neutral on completion state. Falls back to the loading message when omitted.",
),
});
@@ -6,4 +6,6 @@ export type CodeInterpreterFileInput = {
export type CodeInterpreterInput = {
code: string;
files?: CodeInterpreterFileInput[];
loadingMessage: string;
completedMessage?: string;
};
@@ -1,48 +0,0 @@
import { z } from 'zod';
const DEFAULT_LOADING_MESSAGE_SCHEMA = z
.string()
.describe(
"A brief status message for the user describing what you're doing (e.g., 'Sending email to customer').",
);
// Wraps a flat Zod tool schema with loadingMessage for AI execution
export const wrapSchemaForExecution = <T extends z.ZodRawShape>(
schema: z.ZodObject<T>,
customLoadingMessageSchema?: z.ZodString,
): z.ZodObject<T & { loadingMessage: z.ZodString }> => {
return z.object({
loadingMessage:
customLoadingMessageSchema ?? DEFAULT_LOADING_MESSAGE_SCHEMA,
...schema.shape,
}) as z.ZodObject<T & { loadingMessage: z.ZodString }>;
};
// For non-Zod schemas (logic functions with JSON Schema)
export const wrapJsonSchemaForExecution = (
schema: Record<string, unknown>,
): Record<string, unknown> => {
const properties = (schema.properties as Record<string, unknown>) ?? {};
const required = (schema.required as string[]) ?? [];
return {
type: 'object',
properties: {
loadingMessage: {
type: 'string',
description: 'A brief status message for the user.',
},
...properties,
},
required: ['loadingMessage', ...required],
};
};
// Strips loadingMessage from parameters before passing to tool execute
export const stripLoadingMessage = <T extends Record<string, unknown>>(
parameters: T,
): Omit<T, 'loadingMessage'> => {
const { loadingMessage: _, ...rest } = parameters;
return rest;
};
@@ -13,6 +13,7 @@ import {
type UIMessage,
type UITools,
} from 'ai';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { AppPath } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
@@ -125,6 +126,8 @@ export class ChatExecutionService {
workspace.id,
);
const locale = userContext.locale as keyof typeof APP_LOCALES;
const toolContext = {
workspaceId: workspace.id,
roleId,
@@ -132,13 +135,14 @@ export class ChatExecutionService {
userId,
userWorkspaceId,
threadId,
locale,
onCodeExecutionUpdate,
};
const toolCatalog = await this.toolRegistry.buildToolIndex(
workspace.id,
roleId,
{ userId, userWorkspaceId },
{ userId, userWorkspaceId, locale },
);
const skillCatalog = await this.skillService.findAllFlatSkills(
@@ -0,0 +1,14 @@
export const DATABASE_CRUD_OPERATIONS = [
'find_many',
'find_one',
'group_by',
'create_one',
'create_many',
'update_one',
'update_many',
'upsert_many',
'delete_one',
'delete_many',
] as const;
export type DatabaseCrudOperation = (typeof DATABASE_CRUD_OPERATIONS)[number];
+2
View File
@@ -12,6 +12,8 @@ export type { AiSdkPackage } from './constants/ai-sdk-packages.const';
export { AI_SDK_PACKAGES } from './constants/ai-sdk-packages.const';
export type { DataResidency } from './constants/data-residency.const';
export { DATA_RESIDENCY_KEYS } from './constants/data-residency.const';
export type { DatabaseCrudOperation } from './constants/database-crud-operation.const';
export { DATABASE_CRUD_OPERATIONS } from './constants/database-crud-operation.const';
export type { NativeAiSdkProviderId } from './constants/native-ai-sdk-provider-ids.const';
export { NATIVE_AI_SDK_PROVIDER_IDS } from './constants/native-ai-sdk-provider-ids.const';
export { ToolCategory } from './constants/tool-category.const';