feat(ai): add code interpreter for AI data analysis (#16559)

## Summary

- Add code interpreter tool that enables AI to execute Python code for
data analysis, CSV processing, and chart generation
- Support for both local (development) and E2B (sandboxed production)
execution drivers
- Real-time streaming of stdout/stderr and generated files
- Frontend components for displaying code execution results with
expandable sections

## Code Quality Improvements

- Extract `getMimeType` to shared utility to reduce code duplication
between drivers
- Fix security issue: escape single quotes/backslashes in E2B driver env
variable injection
- Add `buildExecutionState` helper to reduce duplicated state object
construction
- Add `DEFAULT_CODE_INTERPRETER_TIMEOUT_MS` constant for consistency
- Fix lingui linting warning and TypeScript theme errors in frontend

## Test Plan

- [ ] Test code interpreter with local driver in development
- [ ] Test code interpreter with E2B driver in production environment
- [ ] Verify streaming output displays correctly in chat UI
- [ ] Verify generated files (charts, CSVs) are uploaded and
downloadable
- [ ] Test file upload flow (CSV, Excel) triggers code interpreter

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Updates generated i18n catalogs for Polish and pseudo-English, adding
strings for code execution/output (code interpreter) and various UI
messages, with minor text adjustments.
> 
> - **Localization**:
> - **Generated catalogs**: Refresh `locales/generated/pl-PL.ts` and
`locales/generated/pseudo-en.ts`.
> - Add strings for code execution/output (e.g., code, copy code/output,
running/waiting states, download files, generated files, Python code
execution).
> - Include new UI texts (errors, prompts, menus) and minor text
corrections.
>   - No changes to `pt-BR`; other files unchanged functionally.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
befc13d02c21e5a6647bc1aa6daa2a89f60b7ef8. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Félix Malfait
2025-12-15 16:11:24 +01:00
committed by GitHub
parent 4281a71f40
commit 2e104c8e76
151 changed files with 10361 additions and 449 deletions
@@ -15,6 +15,8 @@ import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system
export type AgentActorContext = {
actorContext: ActorMetadata;
roleId: string;
userId: string;
userWorkspaceId: string;
};
@Injectable()
@@ -88,6 +90,8 @@ export class AgentActorContextService {
return {
actorContext,
roleId,
userId: userWorkspace.userId,
userWorkspaceId,
};
}
}
@@ -11,72 +11,78 @@ export const mapUIMessagePartsToDBParts = (
uiMessageParts: ExtendedUIMessagePart[],
messageId: string,
): Partial<AgentMessagePartEntity>[] => {
return uiMessageParts.map((part, index) => {
const basePart: Partial<AgentMessagePartEntity> = {
messageId,
orderIndex: index,
type: part.type,
};
return uiMessageParts
.map((part, index) => {
const basePart: Partial<AgentMessagePartEntity> = {
messageId,
orderIndex: index,
type: part.type,
};
switch (part.type) {
case 'text':
return {
...basePart,
textContent: part.text,
};
case 'reasoning':
return {
...basePart,
reasoningContent: part.text,
};
case 'file':
return {
...basePart,
fileMediaType: part.mediaType,
fileFilename: part.filename,
fileUrl: part.url,
};
case 'source-url':
return {
...basePart,
sourceUrlSourceId: part.sourceId,
sourceUrlUrl: part.url,
sourceUrlTitle: part.title,
providerMetadata: part.providerMetadata ?? null,
};
case 'source-document':
return {
...basePart,
sourceDocumentSourceId: part.sourceId,
sourceDocumentMediaType: part.mediaType,
sourceDocumentTitle: part.title,
sourceDocumentFilename: part.filename,
providerMetadata: part.providerMetadata ?? null,
};
case 'step-start':
return basePart;
case 'data-routing-status':
return {
...basePart,
textContent: part.data.text,
state: part.data.state,
};
default:
{
if (isToolPart(part)) {
const { toolCallId, input, output, errorText, state } = part;
switch (part.type) {
case 'text':
return {
...basePart,
textContent: part.text,
};
case 'reasoning':
return {
...basePart,
reasoningContent: part.text,
};
case 'file':
return {
...basePart,
fileMediaType: part.mediaType,
fileFilename: part.filename,
fileUrl: part.url,
};
case 'source-url':
return {
...basePart,
sourceUrlSourceId: part.sourceId,
sourceUrlUrl: part.url,
sourceUrlTitle: part.title,
providerMetadata: part.providerMetadata ?? null,
};
case 'source-document':
return {
...basePart,
sourceDocumentSourceId: part.sourceId,
sourceDocumentMediaType: part.mediaType,
sourceDocumentTitle: part.title,
sourceDocumentFilename: part.filename,
providerMetadata: part.providerMetadata ?? null,
};
case 'step-start':
return basePart;
case 'data-routing-status':
return {
...basePart,
textContent: part.data.text,
state: part.data.state,
};
case 'data-code-execution':
// Code execution parts are streamed during execution but don't need
// to be persisted - the final result is captured in the tool part
return null;
default:
{
if (isToolPart(part)) {
const { toolCallId, input, output, errorText, state } = part;
return {
...basePart,
toolCallId: toolCallId,
toolInput: input,
toolOutput: output,
errorMessage: errorText,
state,
};
return {
...basePart,
toolCallId: toolCallId,
toolInput: input,
toolOutput: output,
errorMessage: errorText,
state,
};
}
}
}
throw new Error(`Unsupported part type: ${part.type}`);
}
});
throw new Error(`Unsupported part type: ${part.type}`);
}
})
.filter((part): part is Partial<AgentMessagePartEntity> => part !== null);
};
@@ -17,7 +17,25 @@ Error recovery:
Permissions:
- Only perform actions your role allows
- Explain limitations if you lack permissions`,
- Explain limitations if you lack permissions
Skills vs Tools:
- SKILLS = documentation/instructions (loaded via \`load_skill\`). They teach you HOW to do something.
- TOOLS = execution capabilities (loaded via \`load_tools\`). They let you DO something.
- Skills don't give you abilities - they give you knowledge. You still need the tool to act.
Python Code Execution:
- To run Python code, you need TWO things:
1. Load the skill for instructions: \`load_skill(["code-interpreter"])\`
2. Load the tool for execution: \`load_tools(["code_interpreter"])\`
- Then call \`code_interpreter\` with your Python code
- The Python environment includes a \`twenty\` helper to call any Twenty tool directly from code
Document Processing (Excel, PDF, Word, PowerPoint):
- For document tasks, load both the skill AND the code_interpreter tool:
1. \`load_skill(["xlsx"])\` or \`load_skill(["pdf"])\` etc. - gets you detailed instructions
2. \`load_tools(["code_interpreter"])\` - enables code execution
- Then use \`code_interpreter\` to run the Python code described in the skill`,
// Response formatting and record references
RESPONSE_FORMAT: `
@@ -3,7 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm';
import { createUIMessageStream, pipeUIMessageStreamToResponse } from 'ai';
import { type Response } from 'express';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import {
type CodeExecutionData,
type ExtendedUIMessage,
} from 'twenty-shared/ai';
import { type Repository } from 'typeorm';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -64,15 +67,23 @@ export class AgentChatStreamingService {
try {
const uiStream = createUIMessageStream<ExtendedUIMessage>({
execute: async ({ writer }) => {
const onCodeExecutionUpdate = (data: CodeExecutionData) => {
writer.write({
type: 'data-code-execution' as const,
id: `code-execution-${data.executionId}`,
data,
});
};
const { stream, modelConfig } =
await this.chatExecutionService.streamChat({
workspace,
userWorkspaceId,
messages,
browsingContext,
onCodeExecutionUpdate,
});
// Write initial status
writer.write({
type: 'data-routing-status' as const,
id: 'execution-status',
@@ -82,7 +93,6 @@ export class AgentChatStreamingService {
},
});
// Track usage from the stream for persisting to thread
let streamUsage = {
inputTokens: 0,
outputTokens: 0,
@@ -90,7 +100,6 @@ export class AgentChatStreamingService {
outputCredits: 0,
};
// Merge the AI stream
writer.merge(
stream.toUIMessageStream({
onError: (error) => {
@@ -146,7 +155,6 @@ export class AgentChatStreamingService {
return;
}
// Update status to completed
writer.write({
type: 'data-routing-status' as const,
id: 'execution-status',
@@ -156,8 +164,6 @@ export class AgentChatStreamingService {
},
});
// Save messages to database
// Use thread.id from the validated thread object to ensure it's not null
const validThreadId = thread.id;
if (!validThreadId) {
@@ -189,7 +195,6 @@ export class AgentChatStreamingService {
turnId: userMessage.turnId,
});
// Update thread usage statistics
await this.threadRepository.update(validThreadId, {
totalInputTokens: () =>
`"totalInputTokens" + ${streamUsage.inputTokens}`,
@@ -14,6 +14,8 @@ import {
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { SkillsService } from 'src/engine/core-modules/skills/skills.service';
import {
@@ -34,6 +36,10 @@ import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agen
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { CHAT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const';
import {
extractCodeInterpreterFiles,
type ExtractedFile,
} from 'src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util';
import {
type AIModelConfig,
ModelProvider,
@@ -46,6 +52,7 @@ export type ChatExecutionOptions = {
userWorkspaceId: string;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
browsingContext: BrowsingContextType | null;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
export type ChatExecutionResult = {
@@ -54,7 +61,6 @@ export type ChatExecutionResult = {
modelConfig: AIModelConfig;
};
// Common tools to pre-load for quick access
const COMMON_PRELOAD_TOOLS = ['http_request', 'search_help_center'];
@Injectable()
@@ -75,14 +81,22 @@ export class ChatExecutionService {
userWorkspaceId,
messages,
browsingContext,
onCodeExecutionUpdate,
}: ChatExecutionOptions): Promise<ChatExecutionResult> {
const { actorContext, roleId } =
const { actorContext, roleId, userId } =
await this.agentActorContextService.buildUserAndAgentActorContext(
userWorkspaceId,
workspace.id,
);
const toolContext = { workspaceId: workspace.id, roleId, actorContext };
const toolContext = {
workspaceId: workspace.id,
roleId,
actorContext,
userId,
userWorkspaceId,
onCodeExecutionUpdate,
};
const contextString = browsingContext
? this.buildContextFromBrowsingContext(workspace, browsingContext)
@@ -139,11 +153,28 @@ export class ChatExecutionService {
),
};
const { processedMessages, extractedFiles } =
extractCodeInterpreterFiles(messages);
let storedFiles: Array<{
filename: string;
storagePath: string;
url: string;
}> = [];
if (extractedFiles.length > 0) {
storedFiles = await this.storeExtractedFiles(
extractedFiles,
workspace.id,
);
}
const systemPrompt = this.buildSystemPrompt(
toolCatalog,
skillCatalog,
preloadedToolNames,
contextString,
storedFiles,
);
this.logger.log(
@@ -153,7 +184,7 @@ export class ChatExecutionService {
const stream = streamText({
model: registeredModel.model,
system: systemPrompt,
messages: convertToModelMessages(messages),
messages: convertToModelMessages(processedMessages),
tools: activeTools,
stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS),
experimental_telemetry: AI_TELEMETRY_CONFIG,
@@ -254,6 +285,7 @@ export class ChatExecutionService {
skillCatalog: Array<{ name: string; label: string; description: string }>,
preloadedTools: string[],
contextString?: string,
storedFiles?: Array<{ filename: string; storagePath: string; url: string }>,
): string {
const parts: string[] = [
CHAT_SYSTEM_PROMPTS.BASE,
@@ -263,6 +295,10 @@ export class ChatExecutionService {
parts.push(this.buildToolCatalogSection(toolCatalog, preloadedTools));
parts.push(this.buildSkillCatalogSection(skillCatalog));
if (storedFiles && storedFiles.length > 0) {
parts.push(this.buildUploadedFilesSection(storedFiles));
}
if (contextString) {
parts.push(
`\nCONTEXT (what the user is currently viewing):\n${contextString}`,
@@ -272,6 +308,30 @@ export class ChatExecutionService {
return parts.join('\n');
}
private buildUploadedFilesSection(
storedFiles: Array<{ filename: string; storagePath: string; url: string }>,
): string {
const fileList = storedFiles.map((f) => `- ${f.filename}`).join('\n');
const filesJson = JSON.stringify(
storedFiles.map((f) => ({ filename: f.filename, url: f.url })),
);
return `
## Uploaded Files
The user has uploaded the following files:
${fileList}
**IMPORTANT**: Use the \`code_interpreter\` tool to analyze these files.
When calling code_interpreter, include the files parameter with these values:
\`\`\`json
${filesJson}
\`\`\`
In your Python code, access files at \`/home/user/{filename}\`.`;
}
private buildSkillCatalogSection(
skillCatalog: Array<{ name: string; label: string; description: string }>,
): string {
@@ -390,4 +450,17 @@ ${tools
return {};
}
}
private async storeExtractedFiles(
files: ExtractedFile[],
_workspaceId: string,
): Promise<Array<{ filename: string; storagePath: string; url: string }>> {
// Files are already uploaded and have URLs, just return them with their info
// The code interpreter tool will download them when needed
return files.map((file) => ({
filename: file.filename,
storagePath: file.filename,
url: file.url,
}));
}
}
@@ -0,0 +1,84 @@
import { type UIMessage } from 'ai';
const CODE_INTERPRETER_MIME_TYPES = new Set([
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-powerpoint',
'application/zip',
'application/x-zip-compressed',
'application/json',
'text/plain',
'text/xml',
'application/xml',
]);
export type ExtractedFile = {
filename: string;
url: string;
mimeType: string;
};
export type ExtractCodeInterpreterFilesResult = {
processedMessages: UIMessage[];
extractedFiles: ExtractedFile[];
};
export const extractCodeInterpreterFiles = (
messages: UIMessage[],
): ExtractCodeInterpreterFilesResult => {
const extractedFiles: ExtractedFile[] = [];
const processedMessages = messages.map((message) => {
if (message.role !== 'user' || !message.parts) {
return message;
}
const newParts: typeof message.parts = [];
const filesForThisMessage: ExtractedFile[] = [];
for (const part of message.parts) {
if (part.type === 'file') {
const mimeType = part.mediaType ?? '';
if (CODE_INTERPRETER_MIME_TYPES.has(mimeType)) {
filesForThisMessage.push({
filename: part.filename ?? 'uploaded_file',
url: part.url,
mimeType,
});
} else {
newParts.push(part);
}
} else {
newParts.push(part);
}
}
if (filesForThisMessage.length > 0) {
extractedFiles.push(...filesForThisMessage);
const fileList = filesForThisMessage
.map((f) => `- ${f.filename} (${f.mimeType})`)
.join('\n');
newParts.push({
type: 'text',
text: `\n\n[Files available for code interpreter at /home/user/:\n${fileList}]\n\nUse the code_interpreter tool to analyze these files.`,
});
}
return {
...message,
parts: newParts,
};
});
return {
processedMessages,
extractedFiles,
};
};
@@ -1,162 +0,0 @@
import { Test } from '@nestjs/testing';
import { jsonSchema } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
const createMockToolRegistry = () => ({
getAllToolTypes: jest.fn(),
getTool: jest.fn(),
});
const createMockPermissions = () => ({
hasToolPermission: jest.fn<
Promise<boolean>,
[RolePermissionConfig, string, PermissionFlagType]
>(),
});
describe('ToolAdapterService', () => {
let mockRegistry: ReturnType<typeof createMockToolRegistry>;
let mockPermissions: ReturnType<typeof createMockPermissions>;
let service: ToolAdapterService;
// Shared tools
const unflaggedToolExecute = jest.fn(async (input: ToolInput) => ({
success: true,
message: 'Tool executed successfully',
result: { echoed: input },
}));
const unflaggedTool: Tool = {
description: 'HTTP Request tool',
inputSchema: jsonSchema({ type: 'object', properties: {} }),
execute: unflaggedToolExecute,
};
const flaggedToolExecute = jest.fn(async (input: ToolInput) => ({
success: true,
message: 'Tool executed successfully',
result: { sent: input },
}));
const flaggedTool: Tool = {
description: 'Send Email tool',
inputSchema: jsonSchema({ type: 'object', properties: {} }),
execute: flaggedToolExecute,
flag: PermissionFlagType.SEND_EMAIL_TOOL,
};
beforeEach(async () => {
jest.clearAllMocks();
mockRegistry = createMockToolRegistry();
mockPermissions = createMockPermissions();
// Setup mock tool responses
mockRegistry.getAllToolTypes.mockReturnValue([
ToolType.HTTP_REQUEST,
ToolType.SEND_EMAIL,
]);
mockRegistry.getTool.mockImplementation((type: ToolType) => {
if (type === ToolType.HTTP_REQUEST) return unflaggedTool;
if (type === ToolType.SEND_EMAIL) return flaggedTool;
throw new Error('Tool not found in mock');
});
const moduleRef = await Test.createTestingModule({
providers: [
ToolAdapterService,
{
provide: ToolRegistryService,
useValue: mockRegistry,
},
{
provide: PermissionsService,
useValue: mockPermissions,
},
],
}).compile();
service = moduleRef.get(ToolAdapterService);
});
it('should include unflagged tools regardless of rolePermissionConfig', async () => {
const toolsNoContext = await service.getTools('ws-1');
expect(Object.keys(toolsNoContext)).toContain('http_request');
const toolsWithPartialContext = await service.getTools('ws-1', {
unionOf: ['role-1'],
});
expect(Object.keys(toolsWithPartialContext)).toContain('http_request');
});
it('should not include flagged tools when rolePermissionConfig is missing', async () => {
const toolsNoRoleConfig = await service.getTools('ws-1');
expect(Object.keys(toolsNoRoleConfig)).not.toContain('send_email');
});
it('should include flagged tools when permission is granted', async () => {
mockPermissions.hasToolPermission.mockResolvedValueOnce(true);
const tools = await service.getTools('ws-1', { unionOf: ['role-1'] });
expect(mockPermissions.hasToolPermission).toHaveBeenCalledWith(
{ unionOf: ['role-1'] },
'ws-1',
PermissionFlagType.SEND_EMAIL_TOOL,
);
expect(Object.keys(tools)).toContain('send_email');
});
it('should exclude flagged tools when permission is denied', async () => {
mockPermissions.hasToolPermission.mockResolvedValueOnce(false);
const tools = await service.getTools('ws-1', { unionOf: ['role-1'] });
expect(Object.keys(tools)).not.toContain('send_email');
});
it('should lowercase tool type keys in the returned ToolSet', async () => {
const tools = await service.getTools('ws-1');
const keys = Object.keys(tools);
expect(keys).toContain('http_request');
expect(keys).not.toContain(ToolType.HTTP_REQUEST); // ensure enum raw value not used as-is
});
it('should forward execute input correctly and return underlying result', async () => {
const tools = await service.getTools('ws-1');
const input = { url: 'https://example.com', method: 'GET' } as ToolInput;
const result = await tools['http_request'].execute?.(
{ input },
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'content',
},
],
},
);
// Ensure wrapper forwards parameters.input and workspaceId
expect(unflaggedToolExecute).toHaveBeenCalledWith(input, 'ws-1');
expect(result).toEqual({
success: true,
message: 'Tool executed successfully',
result: { echoed: input },
});
});
});
@@ -1,54 +0,0 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { type PermissionFlagType } from 'twenty-shared/constants';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@Injectable()
export class ToolAdapterService {
constructor(
private readonly toolRegistry: ToolRegistryService,
private readonly permissionsService: PermissionsService,
) {}
async getTools(
workspaceId: string,
rolePermissionConfig?: RolePermissionConfig,
): Promise<ToolSet> {
const tools: ToolSet = {};
for (const toolType of this.toolRegistry.getAllToolTypes()) {
const tool = this.toolRegistry.getTool(toolType);
if (!tool.flag) {
tools[toolType.toLowerCase()] = this.createToolSet(tool, workspaceId);
} else if (rolePermissionConfig) {
const hasPermission = await this.permissionsService.hasToolPermission(
rolePermissionConfig,
workspaceId,
tool.flag as PermissionFlagType,
);
if (hasPermission) {
tools[toolType.toLowerCase()] = this.createToolSet(tool, workspaceId);
}
}
}
return tools;
}
private createToolSet(tool: Tool, workspaceId: string) {
return {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input, workspaceId),
};
}
}
@@ -9,4 +9,5 @@ export const TOOL_PERMISSION_FLAGS = [
'EXPORT_CSV',
'CONNECTED_ACCOUNTS',
'PROFILE_INFORMATION',
'CODE_INTERPRETER_TOOL',
];
@@ -110,6 +110,7 @@ export class PermissionsService {
[PermissionFlagType.DOWNLOAD_FILE]: false,
[PermissionFlagType.SEND_EMAIL_TOOL]: false,
[PermissionFlagType.HTTP_REQUEST_TOOL]: false,
[PermissionFlagType.CODE_INTERPRETER_TOOL]: false,
[PermissionFlagType.IMPORT_CSV]: false,
[PermissionFlagType.EXPORT_CSV]: false,
[PermissionFlagType.CONNECTED_ACCOUNTS]: false,