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
@@ -30,6 +30,7 @@ const StyledEditorContainer = styled.div<{
color: ${({ theme, readonly }) =>
readonly ? theme.font.color.light : theme.font.color.primary};
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
border: none !important;
@@ -54,15 +55,15 @@ const StyledEditorContainer = styled.div<{
}
h1 {
font-size: 32px;
font-size: 1.5em;
}
h2 {
font-size: 24px;
font-size: 1.3em;
}
h3 {
font-size: 16px;
font-size: 1.1em;
}
li {
@@ -84,17 +84,18 @@ export const AIChatThreadGroup = ({
const handleThreadClick = (thread: AgentChatThread) => {
setCurrentAIChatThread(thread.id);
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
const hasUsageData =
totalTokens > 0 && isDefined(thread.contextWindowTokens);
(thread.conversationSize ?? 0) > 0 &&
isDefined(thread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: thread.conversationSize ?? 0,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputTokens: thread.totalInputTokens,
outputTokens: thread.totalOutputTokens,
totalTokens,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputCredits: thread.totalInputCredits,
outputCredits: thread.totalOutputCredits,
}
@@ -9,7 +9,10 @@ import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
import { ShimmeringText } from '@/ai/components/ShimmeringText';
import { getToolIcon } from '@/ai/utils/getToolIcon';
import { getToolDisplayMessage } from '@/ai/utils/getWebSearchToolDisplayMessage';
import {
getToolDisplayMessage,
resolveToolInput,
} from '@/ai/utils/getToolDisplayMessage';
import { useLingui } from '@lingui/react/macro';
import { type ToolUIPart } from 'ai';
import { isDefined } from 'twenty-shared/utils';
@@ -132,12 +135,10 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
const [activeTab, setActiveTab] = useState<TabType>('output');
const { input, output, type, errorText } = toolPart;
const toolName = type.split('-')[1];
const rawToolName = type.split('-')[1];
const toolInput =
isDefined(input) && typeof input === 'object' && 'input' in input
? input.input
: input;
const { resolvedInput: toolInput, resolvedToolName: toolName } =
resolveToolInput(input, rawToolName);
const hasError = isDefined(errorText);
const isExpandable = isDefined(output) || hasError;
@@ -175,7 +176,7 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
<StyledLoadingContainer>
<ShimmeringText>
<StyledDisplayMessage>
{getToolDisplayMessage(input, toolName, false)}
{getToolDisplayMessage(input, rawToolName, false)}
</StyledDisplayMessage>
</ShimmeringText>
</StyledLoadingContainer>
@@ -188,19 +189,32 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
);
}
// For execute_tool, the actual result is nested inside output.result
const unwrappedOutput =
rawToolName === 'execute_tool' &&
isDefined(output) &&
typeof output === 'object' &&
'result' in output
? (output as { result: unknown }).result
: output;
const displayMessage = hasError
? t`Tool execution failed`
: output &&
typeof output === 'object' &&
'message' in output &&
typeof output.message === 'string'
? output.message
: getToolDisplayMessage(input, toolName, true);
: rawToolName === 'learn_tools' || rawToolName === 'execute_tool'
? getToolDisplayMessage(input, rawToolName, true)
: unwrappedOutput &&
typeof unwrappedOutput === 'object' &&
'message' in unwrappedOutput &&
typeof unwrappedOutput.message === 'string'
? unwrappedOutput.message
: getToolDisplayMessage(input, rawToolName, true);
const result =
output && typeof output === 'object' && 'result' in output
? (output as { result: string }).result
: output;
unwrappedOutput &&
typeof unwrappedOutput === 'object' &&
'result' in unwrappedOutput
? (unwrappedOutput as { result: string }).result
: unwrappedOutput;
const ToolIcon = getToolIcon(toolName);
@@ -82,8 +82,10 @@ print("Chart saved successfully!")`,
usage: {
inputTokens: 1250,
outputTokens: 890,
cachedInputTokens: 0,
inputCredits: 12,
outputCredits: 8,
conversationSize: 1250,
},
},
};
@@ -1,12 +1,17 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { ProgressBar } from 'twenty-ui/feedback';
import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing';
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import {
agentChatUsageState,
type AgentChatLastMessageUsage,
} from '@/ai/states/agentChatUsageState';
const StyledContainer = styled.div`
position: relative;
@@ -41,14 +46,14 @@ const StyledHoverCard = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
min-width: 240px;
min-width: 280px;
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: ${({ theme }) => theme.lastLayerZIndex};
`;
const StyledHeader = styled.div`
const StyledSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
@@ -61,14 +66,6 @@ const StyledRow = styled.div`
justify-content: space-between;
`;
const StyledBody = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(3)};
padding-top: 0;
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
@@ -79,15 +76,16 @@ const StyledValue = styled.span`
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledFooter = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
const StyledSectionTitle = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
text-transform: uppercase;
letter-spacing: 0.5px;
`;
const StyledDivider = styled.div`
border-top: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: 0 0 ${({ theme }) => theme.border.radius.md}
${({ theme }) => theme.border.radius.md};
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(3)};
`;
const formatTokenCount = (count: number): string => {
@@ -103,6 +101,31 @@ const formatTokenCount = (count: number): string => {
return count.toString();
};
const formatCredits = (credits: number): string => {
// Credits are already in display units from the API (internal / 1000)
// Show up to 1 decimal for fractional values, none for whole numbers
if (Number.isInteger(credits)) {
return credits.toLocaleString();
}
return credits.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
};
const getCachedLabel = (lastMessage: AgentChatLastMessageUsage): string => {
if (lastMessage.cachedInputTokens <= 0 || lastMessage.inputTokens <= 0) {
return '';
}
const cachedPercent = Math.round(
(lastMessage.cachedInputTokens / lastMessage.inputTokens) * 100,
);
return ` (${t`${cachedPercent}% cached`})`;
};
export const AIChatContextUsageButton = () => {
const { t } = useLingui();
const theme = useTheme();
@@ -121,14 +144,14 @@ export const AIChatContextUsageButton = () => {
}
const percentage = Math.min(
(agentChatUsage.totalTokens / agentChatUsage.contextWindowTokens) * 100,
(agentChatUsage.conversationSize / agentChatUsage.contextWindowTokens) *
100,
100,
);
const formattedPercentage = percentage.toFixed(1);
const totalCredits =
agentChatUsage.inputCredits + agentChatUsage.outputCredits;
const inputCredits = agentChatUsage.inputCredits.toLocaleString();
const outputCredits = agentChatUsage.outputCredits.toLocaleString();
const lastMessage = agentChatUsage.lastMessage;
return (
<StyledContainer
@@ -142,12 +165,13 @@ export const AIChatContextUsageButton = () => {
{isHovered && (
<StyledHoverCard>
<StyledHeader>
<StyledSection>
<StyledRow>
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
<StyledValue>
{formatTokenCount(agentChatUsage.totalTokens)} /{' '}
{formatTokenCount(agentChatUsage.contextWindowTokens)}
{formatTokenCount(agentChatUsage.conversationSize)} /{' '}
{formatTokenCount(agentChatUsage.contextWindowTokens)}{' '}
{t`tokens`}
</StyledValue>
</StyledRow>
<ProgressBar
@@ -162,29 +186,61 @@ export const AIChatContextUsageButton = () => {
backgroundColor={theme.background.quaternary}
withBorderRadius
/>
</StyledHeader>
</StyledSection>
<StyledBody>
{isDefined(lastMessage) && (
<>
<StyledDivider />
<StyledSection>
<StyledSectionTitle>{t`Last message`}</StyledSectionTitle>
<StyledRow>
<StyledLabel>{t`Input tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(lastMessage.inputTokens)}
{getCachedLabel(lastMessage)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Output tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(lastMessage.outputTokens)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Cost`}</StyledLabel>
<StyledValue>
{formatCredits(
lastMessage.inputCredits + lastMessage.outputCredits,
)}{' '}
{t`credits`}
</StyledValue>
</StyledRow>
</StyledSection>
</>
)}
<StyledDivider />
<StyledSection>
<StyledSectionTitle>{t`Conversation`}</StyledSectionTitle>
<StyledRow>
<StyledLabel>{t`Input`}</StyledLabel>
<StyledLabel>{t`Input tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.inputTokens)} {' '}
{t`${inputCredits} credits`}
{formatTokenCount(agentChatUsage.inputTokens)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Output`}</StyledLabel>
<StyledLabel>{t`Output tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.outputTokens)} {' '}
{t`${outputCredits} credits`}
{formatTokenCount(agentChatUsage.outputTokens)}
</StyledValue>
</StyledRow>
</StyledBody>
<StyledFooter>
<StyledLabel>{t`Total credits`}</StyledLabel>
<StyledPercentage>{totalCredits.toLocaleString()}</StyledPercentage>
</StyledFooter>
<StyledRow>
<StyledLabel>{t`Total cost`}</StyledLabel>
<StyledValue>
{formatCredits(totalCredits)} {t`credits`}
</StyledValue>
</StyledRow>
</StyledSection>
</StyledHoverCard>
)}
</StyledContainer>
@@ -8,6 +8,7 @@ export const GET_CHAT_THREADS = gql`
totalInputTokens
totalOutputTokens
contextWindowTokens
conversationSize
totalInputCredits
totalOutputCredits
createdAt
@@ -119,8 +119,10 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
type UsageMetadata = {
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
inputCredits: number;
outputCredits: number;
conversationSize: number;
};
type ModelMetadata = {
contextWindowTokens: number;
@@ -133,11 +135,17 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
if (isDefined(usage) && isDefined(model)) {
setAgentChatUsage((prev) => ({
lastMessage: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cachedInputTokens: usage.cachedInputTokens,
inputCredits: usage.inputCredits,
outputCredits: usage.outputCredits,
},
conversationSize: usage.conversationSize,
contextWindowTokens: model.contextWindowTokens,
inputTokens: (prev?.inputTokens ?? 0) + usage.inputTokens,
outputTokens: (prev?.outputTokens ?? 0) + usage.outputTokens,
totalTokens:
(prev?.totalTokens ?? 0) + usage.inputTokens + usage.outputTokens,
contextWindowTokens: model.contextWindowTokens,
inputCredits: (prev?.inputCredits ?? 0) + usage.inputCredits,
outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits,
}));
@@ -23,16 +23,17 @@ const setUsageFromThread = (
thread: AgentChatThread,
setAgentChatUsage: SetterOrUpdater<AgentChatUsageState | null>,
) => {
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
const hasUsageData = totalTokens > 0 && isDefined(thread.contextWindowTokens);
const hasUsageData =
(thread.conversationSize ?? 0) > 0 && isDefined(thread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: thread.conversationSize ?? 0,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputTokens: thread.totalInputTokens,
outputTokens: thread.totalOutputTokens,
totalTokens,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputCredits: thread.totalInputCredits,
outputCredits: thread.totalOutputCredits,
}
@@ -1,10 +1,19 @@
import { atom } from 'recoil';
export type AgentChatUsageState = {
export type AgentChatLastMessageUsage = {
inputTokens: number;
outputTokens: number;
totalTokens: number;
cachedInputTokens: number;
inputCredits: number;
outputCredits: number;
};
export type AgentChatUsageState = {
lastMessage: AgentChatLastMessageUsage | null;
conversationSize: number;
contextWindowTokens: number;
inputTokens: number;
outputTokens: number;
inputCredits: number;
outputCredits: number;
};
@@ -8,6 +8,7 @@ describe('groupThreadsByDate', () => {
totalInputTokens: 0,
totalOutputTokens: 0,
contextWindowTokens: null,
conversationSize: 0,
totalInputCredits: 0,
totalOutputCredits: 0,
};
@@ -0,0 +1,99 @@
import { type ToolInput } from '@/ai/types/ToolInput';
import { isDefined } from 'twenty-shared/utils';
const extractSearchQuery = (input: ToolInput): string => {
if (!input) {
return '';
}
if (
typeof input === 'object' &&
'query' in input &&
typeof input.query === 'string'
) {
return input.query;
}
if (
typeof input === 'object' &&
'action' in input &&
isDefined(input.action) &&
typeof input.action === 'object' &&
'query' in input.action &&
typeof input.action.query === 'string'
) {
return input.action.query;
}
return '';
};
const extractLoadingMessage = (input: ToolInput): string => {
if (
isDefined(input) &&
typeof input === 'object' &&
'loadingMessage' in input &&
typeof input.loadingMessage === 'string'
) {
return input.loadingMessage;
}
return 'Processing...';
};
export const resolveToolInput = (
input: ToolInput,
toolName: string,
): { resolvedInput: ToolInput; resolvedToolName: string } => {
if (
toolName === 'execute_tool' &&
isDefined(input) &&
typeof input === 'object' &&
'toolName' in input &&
'arguments' in input
) {
return {
resolvedInput: input.arguments as ToolInput,
resolvedToolName: String(input.toolName),
};
}
return { resolvedInput: input, resolvedToolName: toolName };
};
const extractLearnToolNames = (input: ToolInput): string => {
if (
isDefined(input) &&
typeof input === 'object' &&
'toolNames' in input &&
Array.isArray(input.toolNames)
) {
return input.toolNames.join(', ');
}
return '';
};
export const getToolDisplayMessage = (
input: ToolInput,
toolName: string,
isFinished?: boolean,
): string => {
const { resolvedInput, resolvedToolName } = resolveToolInput(input, toolName);
if (resolvedToolName === 'web_search') {
const query = extractSearchQuery(resolvedInput);
const action = isFinished ? 'Searched' : 'Searching';
return query ? `${action} the web for '${query}'` : `${action} the web`;
}
if (resolvedToolName === 'learn_tools') {
const names = extractLearnToolNames(resolvedInput);
const action = isFinished ? 'Learned' : 'Learning';
return names ? `${action} ${names}` : `${action} tools...`;
}
return extractLoadingMessage(resolvedInput);
};
@@ -1,6 +1,16 @@
import { IconDatabase, IconMail, IconTool, IconWorld } from 'twenty-ui/display';
import {
IconBook2,
IconDatabase,
IconMail,
IconTool,
IconWorld,
} from 'twenty-ui/display';
const TOOL_ICON_MAPPINGS = [
{
keywords: ['learn_tools'],
icon: IconBook2,
},
{
keywords: ['email'],
icon: IconMail,
@@ -1,56 +0,0 @@
import { type ToolInput } from '@/ai/types/ToolInput';
import { isDefined } from 'twenty-shared/utils';
const extractSearchQuery = (input: ToolInput): string => {
if (!input) {
return '';
}
if (
typeof input === 'object' &&
'query' in input &&
typeof input.query === 'string'
) {
return input.query;
}
if (
typeof input === 'object' &&
'action' in input &&
isDefined(input.action) &&
typeof input.action === 'object' &&
'query' in input.action &&
typeof input.action.query === 'string'
) {
return input.action.query;
}
return '';
};
const extractLoadingMessage = (input: ToolInput): string => {
if (
isDefined(input) &&
typeof input === 'object' &&
'loadingMessage' in input &&
typeof input.loadingMessage === 'string'
) {
return input.loadingMessage;
}
return 'Processing...';
};
export const getToolDisplayMessage = (
input: ToolInput,
toolName: string,
isFinished?: boolean,
): string => {
if (toolName === 'web_search') {
const query = extractSearchQuery(input);
const action = isFinished ? 'Searched' : 'Searching';
return query ? `${action} the web for '${query}'` : `${action} the web`;
}
return extractLoadingMessage(input);
};
@@ -185,6 +185,12 @@ const SettingsSkillForm = lazy(() =>
})),
);
const SettingsAIPrompts = lazy(() =>
import('~/pages/settings/ai/SettingsAIPrompts').then((module) => ({
default: module.SettingsAIPrompts,
})),
);
const SettingsWorkspaceMembers = lazy(() =>
import('~/pages/settings/members/SettingsWorkspaceMembers').then(
(module) => ({
@@ -442,6 +448,7 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
element={<SettingsApiWebhooks />}
/>
<Route path={SettingsPath.AI} element={<SettingsAI />} />
<Route path={SettingsPath.AIPrompts} element={<SettingsAIPrompts />} />
<Route
path={SettingsPath.AINewAgent}
element={<SettingsAgentForm mode="create" />}
@@ -36,6 +36,7 @@ export type CurrentWorkspace = Pick<
| 'eventLogRetentionDays'
| 'fastModel'
| 'smartModel'
| 'aiAdditionalInstructions'
| 'editableProfileFields'
> & {
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;
@@ -88,6 +88,7 @@ export const USER_QUERY_FRAGMENT = gql`
}
fastModel
smartModel
aiAdditionalInstructions
isTwoFactorAuthenticationEnforced
trashRetentionDays
eventLogRetentionDays
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const GET_AI_SYSTEM_PROMPT_PREVIEW = gql`
query GetAISystemPromptPreview {
getAISystemPromptPreview {
sections {
title
content
estimatedTokenCount
}
estimatedTokenCount
}
}
`;