Files
twenty/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx
T
Etienne 5ca41d55fb 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. -->
2026-06-24 13:41:09 +02:00

348 lines
10 KiB
TypeScript

import { styled } from '@linaria/react';
import { useContext, useState } from 'react';
import { IconChevronDown, IconChevronUp } from 'twenty-ui/icon';
import { JsonTree } from 'twenty-ui/json-visualizer';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
import { ShimmeringText } from '@/ai/components/ShimmeringText';
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';
import { isDefined } from 'twenty-shared/utils';
import { type JsonValue } from 'type-fest';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
font-family: ${themeCssVariables.font.family};
gap: ${themeCssVariables.spacing[2]};
`;
const StyledContentContainer = styled.div`
background: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.sm};
min-width: 0;
padding: ${themeCssVariables.spacing[3]};
`;
const StyledJsonTreeContainer = styled.div`
overflow-x: auto;
ul {
min-width: 0;
}
`;
const StyledToggleButton = styled.div<{ isExpandable: boolean }>`
align-items: center;
background: none;
border: none;
color: ${themeCssVariables.font.color.tertiary};
cursor: ${({ isExpandable }) => (isExpandable ? 'pointer' : 'auto')};
display: flex;
gap: ${themeCssVariables.spacing[1]};
justify-content: space-between;
padding: ${themeCssVariables.spacing[1]} 0;
transition: color calc(${themeCssVariables.animation.duration.fast} * 1s)
ease-in-out;
width: 100%;
&:hover {
color: ${themeCssVariables.font.color.primary};
}
`;
const StyledToolName = styled.span`
background: ${themeCssVariables.background.transparent.light};
border-radius: ${themeCssVariables.border.radius.xs};
color: ${themeCssVariables.font.color.light};
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.xs};
padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]};
`;
const StyledLeftContent = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledRightContent = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledDisplayMessage = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledIconTextContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
svg {
min-width: calc(${themeCssVariables.icon.size.sm} * 1px);
}
`;
const StyledTabContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
display: flex;
gap: ${themeCssVariables.spacing[3]};
margin-bottom: ${themeCssVariables.spacing[3]};
`;
const StyledTab = styled.div<{ isActive: boolean }>`
color: ${({ isActive }) =>
isActive
? themeCssVariables.font.color.primary
: themeCssVariables.font.color.tertiary};
cursor: pointer;
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${({ isActive }) =>
isActive
? themeCssVariables.font.weight.medium
: themeCssVariables.font.weight.regular};
padding-bottom: ${themeCssVariables.spacing[2]};
transition: color calc(${themeCssVariables.animation.duration.fast} * 1s)
ease-in-out;
&:hover {
color: ${themeCssVariables.font.color.primary};
}
`;
type TabType = 'output' | 'input';
export const ToolStepRenderer = ({
toolPart,
isStreaming,
}: {
toolPart: ToolUIPart | DynamicToolUIPart;
isStreaming: boolean;
}) => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const [isExpanded, setIsExpanded] = useState(false);
const [activeTab, setActiveTab] = useState<TabType>('output');
const { input, output, errorText } = toolPart;
const rawToolName = getToolName(toolPart);
const { toolInput, toolName } = unwrapToolInput({
input,
toolName: rawToolName,
});
const displayContext = useToolDisplayContext();
const hasError = isDefined(errorText);
const isCodeInterpreter = toolName === 'code_interpreter';
const isExpandable = isDefined(output) || hasError || isCodeInterpreter;
const ToolIcon = getToolIcon(toolName);
const outputObj =
typeof output === 'object' && output !== null
? (output as Record<string, unknown>)
: null;
const toolMessage =
typeof outputObj?.message === 'string' ? outputObj.message : null;
const toolError =
typeof outputObj?.error === 'string' ? outputObj.error : null;
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 = getToolDisplayMessage({
input,
toolName: rawToolName,
isFinished: !isStreaming,
displayContext,
output,
});
return (
<StyledContainer>
<StyledToggleButton
isExpandable={isCodeInterpreter}
onClick={
isCodeInterpreter ? () => setIsExpanded(!isExpanded) : undefined
}
>
<StyledLeftContent>
<StyledIconTextContainer>
<ToolIcon size={theme.icon.size.sm} />
{isStreaming ? (
<ShimmeringText>
<StyledDisplayMessage>{displayText}</StyledDisplayMessage>
</ShimmeringText>
) : (
<StyledDisplayMessage>{displayText}</StyledDisplayMessage>
)}
</StyledIconTextContainer>
</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>
);
}
const displayMessage = hasError
? t`Tool execution failed`
: rawToolName === 'learn_tools' ||
rawToolName === 'execute_tool' ||
rawToolName === 'load_skills'
? 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
onClick={() => setIsExpanded(!isExpanded)}
isExpandable={isExpandable}
>
<StyledLeftContent>
<StyledIconTextContainer>
<ToolIcon size={theme.icon.size.sm} />
<StyledDisplayMessage>{displayMessage}</StyledDisplayMessage>
</StyledIconTextContainer>
</StyledLeftContent>
<StyledRightContent>
<StyledToolName>{toolName}</StyledToolName>
{isExpandable &&
(isExpanded ? (
<IconChevronUp size={theme.icon.size.sm} />
) : (
<IconChevronDown size={theme.icon.size.sm} />
))}
</StyledRightContent>
</StyledToggleButton>
{isExpandable && (
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
{isCodeInterpreter ? (
renderExpandedContent()
) : (
<StyledContentContainer>
{renderExpandedContent()}
</StyledContentContainer>
)}
</AnimatedExpandableContainer>
)}
</StyledContainer>
);
};