feat: improve AI chat - system prompt, tool output, context window display (#17769)

⚠️ **AI-generated PR — not ready for review** ⚠️

cc @FelixMalfait

---

## Changes

### System prompt improvements
- Explicit skill-before-tools workflow to prevent the model from calling
tools without loading the matching skill first
- Data efficiency guidance (default small limits, use filters)
- Pluralized `load_skill` → `load_skills` for consistency with
`load_tools`

### Token usage reduction
- Output serialization layer: strips null/undefined/empty values from
tool results
- Lowered default `find_*` limit from 100 → 10, max from 1000 → 100

### System object tool generation
- System objects (calendar events, messages, etc.) now generate AI tools
- Only workflow-related and favorite-related objects are excluded

### Context window display fix
- **Bug**: UI compared cumulative tokens (sum of all turns) against
single-request context window → showed 100% after a few turns
- **Fix**: Track `conversationSize` (last step's `inputTokens`) which
represents the actual conversation history size sent to the model
- New `conversationSize` column on thread entity with migration

### Workspace AI instructions
- Support for custom workspace-level AI instructions

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-09 14:26:02 +01:00
committed by GitHub
parent 6c7c389785
commit 3216b634a3
105 changed files with 3245 additions and 1714 deletions
@@ -1,3 +1,7 @@
import styled from '@emotion/styled';
import { Link } from 'react-router-dom';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
@@ -7,13 +11,25 @@ import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { t } from '@lingui/core/macro';
import { IconSettings, IconSparkles, IconTool } from 'twenty-ui/display';
import {
H2Title,
IconFileText,
IconSettings,
IconSparkles,
IconTool,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { SettingsAIMCP } from './components/SettingsAIMCP';
import { SettingsAIRouterSettings } from './components/SettingsAIRouterSettings';
import { SettingsSkillsTable } from './components/SettingsSkillsTable';
import { SettingsToolsTable } from './components/SettingsToolsTable';
import { SETTINGS_AI_TABS } from './constants/SettingsAiTabs';
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const SettingsAI = () => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
@@ -63,6 +79,28 @@ export const SettingsAI = () => {
{isSettingsTab && (
<>
<SettingsAIRouterSettings />
<Section>
<H2Title
title={t`System Prompt`}
description={t`View and customize AI instructions`}
/>
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconFileText}
title={t`System Prompt`}
description={t`View the AI system prompt and add custom instructions`}
Button={
<StyledLink to={getSettingsPath(SettingsPath.AIPrompts)}>
<Button
title={t`Configure`}
variant="secondary"
size="small"
/>
</StyledLink>
}
/>
</Card>
</Section>
<SettingsAIMCP />
</>
)}
@@ -0,0 +1,263 @@
import { ApolloError } from '@apollo/client';
import styled from '@emotion/styled';
import { useDebouncedCallback } from 'use-debounce';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
useGetAiSystemPromptPreviewQuery,
useUpdateWorkspaceMutation,
} from '~/generated-metadata/graphql';
const StyledFormContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledTokenBadge = styled.span`
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.xs};
padding: ${({ theme }) => theme.spacing(0.5)}
${({ theme }) => theme.spacing(1.5)};
white-space: nowrap;
`;
export const SettingsAIPrompts = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const [updateWorkspace] = useUpdateWorkspaceMutation();
const { data: previewData, loading: previewLoading } =
useGetAiSystemPromptPreviewQuery();
const [workspaceInstructions, setWorkspaceInstructions] = useState(
currentWorkspace?.aiAdditionalInstructions ?? '',
);
const [originalInstructions, setOriginalInstructions] = useState(
currentWorkspace?.aiAdditionalInstructions ?? '',
);
const handleWorkspaceInstructionsInit = () => {
if (currentWorkspace?.aiAdditionalInstructions !== undefined) {
setWorkspaceInstructions(currentWorkspace.aiAdditionalInstructions ?? '');
setOriginalInstructions(currentWorkspace.aiAdditionalInstructions ?? '');
}
};
if (
currentWorkspace?.aiAdditionalInstructions !== undefined &&
originalInstructions === '' &&
currentWorkspace.aiAdditionalInstructions !== null &&
currentWorkspace.aiAdditionalInstructions !== originalInstructions
) {
handleWorkspaceInstructionsInit();
}
const autoSave = useDebouncedCallback(async (newValue: string) => {
if (!currentWorkspace?.id || newValue === originalInstructions) {
return;
}
try {
setCurrentWorkspace({
...currentWorkspace,
aiAdditionalInstructions: newValue || null,
});
await updateWorkspace({
variables: {
input: {
aiAdditionalInstructions: newValue || null,
},
},
});
setOriginalInstructions(newValue);
} catch (error) {
setCurrentWorkspace({
...currentWorkspace,
aiAdditionalInstructions: originalInstructions || null,
});
if (error instanceof ApolloError) {
enqueueErrorSnackBar({
apolloError: error,
});
} else {
enqueueErrorSnackBar({
message: t`Failed to save workspace instructions`,
});
}
}
}, 1000);
const handleWorkspaceInstructionsChange = (value: string) => {
setWorkspaceInstructions(value);
autoSave(value);
};
const preview = previewData?.getAISystemPromptPreview;
const sections = preview?.sections ?? [];
const buildUserContextPreview = (): string => {
if (!isDefined(currentWorkspaceMember)) {
return '';
}
const parts = [
`**${t`User`}:** ${currentWorkspaceMember.name.firstName} ${currentWorkspaceMember.name.lastName}`.trim(),
`**${t`Locale`}:** ${currentWorkspaceMember.locale ?? 'en'}`,
];
if (isDefined(currentWorkspaceMember.timeZone)) {
parts.push(`**${t`Timezone`}:** ${currentWorkspaceMember.timeZone}`);
}
return parts.join('\n\n');
};
const userContextPreview = buildUserContextPreview();
const promptSections = sections.filter(
(section) =>
section.title !== 'Workspace Instructions' &&
section.title !== 'User Context',
);
const formatTokenCount = (count: number): string => {
if (count >= 1000) {
const kTokens = (count / 1000).toFixed(1);
return t`~${kTokens}k tokens`;
}
return t`~${count} tokens`;
};
const totalTokenCount = isDefined(preview)
? formatTokenCount(preview.estimatedTokenCount)
: '';
const pageTitle = isDefined(preview)
? t`System Prompt (${totalTokenCount})`
: t`System Prompt`;
return (
<SubMenuTopBarContainer
title={pageTitle}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: t`AI`, href: getSettingsPath(SettingsPath.AI) },
{ children: t`System Prompt` },
]}
>
<SettingsPageContainer>
{promptSections.map((section) => (
<Section key={section.title}>
<H2Title
title={section.title}
description={t`Read-only — managed by Twenty`}
adornment={
<StyledTokenBadge>
{formatTokenCount(section.estimatedTokenCount)}
</StyledTokenBadge>
}
/>
<StyledFormContainer>
<FormAdvancedTextFieldInput
key={
previewLoading ? `loading-${section.title}` : section.title
}
label={section.title}
readonly={true}
defaultValue={section.content}
contentType="markdown"
onChange={() => {}}
enableFullScreen={true}
fullScreenBreadcrumbs={[
{
children: t`System Prompt`,
href: '#',
},
{
children: section.title,
},
]}
minHeight={120}
maxWidth={700}
/>
</StyledFormContainer>
</Section>
))}
<Section>
<H2Title
title={t`Workspace Instructions`}
description={t`Add custom instructions specific to your workspace (appended to system prompt)`}
/>
<StyledFormContainer>
<FormAdvancedTextFieldInput
key={originalInstructions}
label={t`Additional Instructions`}
readonly={false}
defaultValue={workspaceInstructions}
contentType="markdown"
onChange={handleWorkspaceInstructionsChange}
enableFullScreen={true}
fullScreenBreadcrumbs={[
{
children: t`System Prompt`,
href: '#',
},
{
children: t`Workspace Instructions`,
},
]}
placeholder={t`E.g., "We are a B2B SaaS company. Always use formal language..."`}
minHeight={150}
maxWidth={700}
/>
</StyledFormContainer>
</Section>
<Section>
<H2Title
title={t`User Context`}
description={t`Information about the current user (auto-generated and included in each request)`}
/>
<StyledFormContainer>
<FormAdvancedTextFieldInput
label={t`User Information`}
readonly={true}
defaultValue={userContextPreview}
contentType="markdown"
onChange={() => {}}
enableFullScreen={false}
minHeight={80}
maxWidth={700}
/>
</StyledFormContainer>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};