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
@@ -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;
};