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