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:
+4
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+71
-65
@@ -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);
|
||||
};
|
||||
|
||||
+19
-1
@@ -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: `
|
||||
|
||||
+13
-8
@@ -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}`,
|
||||
|
||||
+77
-4
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+84
@@ -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,
|
||||
};
|
||||
};
|
||||
-162
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
-54
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user