diff --git a/packages/twenty-server/src/engine/core-modules/code-interpreter/code-interpreter-driver.factory.ts b/packages/twenty-server/src/engine/core-modules/code-interpreter/code-interpreter-driver.factory.ts index 295986a9ff..b4ccb4ffae 100644 --- a/packages/twenty-server/src/engine/core-modules/code-interpreter/code-interpreter-driver.factory.ts +++ b/packages/twenty-server/src/engine/core-modules/code-interpreter/code-interpreter-driver.factory.ts @@ -52,7 +52,12 @@ export class CodeInterpreterDriverFactory extends DriverFactoryBase" } +// - writes one response per request to fd 4: +// { "stdout": "...", "stderr": "...", "exitCode": 0 | 1 } +// The namespace dict is reused for every request, so user-defined variables and +// imports survive between executions. +// +// A single mechanism guarantees the process is always destroyed: an idle +// watchdog thread that self-terminates after KERNEL_IDLE_TIMEOUT_MS of +// inactivity (0 disables it). As a free side effect of reading control input, +// the read loop also ends on EOF (fd 3) when the parent dies, so the process +// exits immediately on dev-server shutdown too. +export const LOCAL_DRIVER_PERSISTENT_KERNEL_SCRIPT = `import sys +import os +import io +import json +import base64 +import contextlib +import traceback +import threading +import time + +_last_activity = time.time() + +def _idle_watchdog(idle_ms): + while True: + time.sleep(5) + if (time.time() - _last_activity) * 1000 > idle_ms: + os._exit(0) + +def _main(): + global _last_activity + + idle_ms = int(os.environ.get('KERNEL_IDLE_TIMEOUT_MS', '0')) + if idle_ms > 0: + threading.Thread(target=_idle_watchdog, args=(idle_ms,), daemon=True).start() + + control_in = os.fdopen(3, 'r') + control_out = os.fdopen(4, 'w') + namespace = {'__name__': '__main__'} + + for line in control_in: + _last_activity = time.time() + line = line.strip() + if not line: + continue + + try: + request = json.loads(line) + except Exception: + continue + + try: + code = base64.b64decode(request.get('code', '')).decode('utf-8') + except Exception: + code = '' + + stdout_buffer = io.StringIO() + stderr_buffer = io.StringIO() + error = None + + with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer): + try: + exec(compile(code, '', 'exec'), namespace) + except SystemExit: + pass + except BaseException: + error = traceback.format_exc() + + stderr_value = stderr_buffer.getvalue() + if error: + stderr_value = stderr_value + error + + response = { + 'stdout': stdout_buffer.getvalue(), + 'stderr': stderr_value, + 'exitCode': 1 if error else 0, + } + control_out.write(json.dumps(response) + '\\n') + control_out.flush() + _last_activity = time.time() + +_main() +`; diff --git a/packages/twenty-server/src/engine/core-modules/code-interpreter/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/code-interpreter/drivers/local.driver.ts index 2ae24608d4..1e41ef64b3 100644 --- a/packages/twenty-server/src/engine/core-modules/code-interpreter/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/code-interpreter/drivers/local.driver.ts @@ -1,9 +1,14 @@ -import { spawn } from 'child_process'; +import { type ChildProcess, spawn } from 'child_process'; import { promises as fs } from 'fs'; import { tmpdir } from 'os'; import { basename, join } from 'path'; +import { type Readable, type Writable } from 'stream'; + +import { isDefined } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; import { DEFAULT_CODE_INTERPRETER_TIMEOUT_MS } from 'src/engine/core-modules/code-interpreter/code-interpreter.constants'; +import { LOCAL_DRIVER_PERSISTENT_KERNEL_SCRIPT } from 'src/engine/core-modules/code-interpreter/drivers/local-driver-kernel.const'; import { getMimeType } from 'src/engine/core-modules/code-interpreter/utils/get-mime-type.util'; import { @@ -17,10 +22,32 @@ import { export type LocalDriverOptions = { timeoutMs?: number; + idleTimeoutMs?: number; }; const SANDBOX_SCRIPTS_PATH = join(__dirname, '..', 'sandbox-scripts'); +type KernelResponse = { + stdout: string; + stderr: string; + exitCode: number; +}; + +type LocalSession = { + workDir: string; + outputDir: string; + scriptsDir: string; + child: ChildProcess; + controlIn: Writable; + controlOut: Readable; + controlBuffer: string; + pending?: { + resolve: (response: KernelResponse) => void; + reject: (error: Error) => void; + }; + hasExited: boolean; +}; + async function copyDirectoryRecursive(src: string, dest: string) { await fs.mkdir(dest, { recursive: true }); @@ -38,9 +65,31 @@ async function copyDirectoryRecursive(src: string, dest: string) { } } -// WARNING: This driver is UNSAFE and should only be used for development. +const buildEnvSetup = (env?: Record): string => { + if (!isDefined(env)) { + return ''; + } + + const assignments = Object.entries(env) + .map(([key, value]) => { + const escapedValue = value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r'); + + return `os.environ['${key}'] = '${escapedValue}'`; + }) + .join('\n'); + + return `import os\n${assignments}\n\n`; +}; + +// WARNING: This driver is UNSAFE and can be used for development only. // It executes arbitrary Python code on the server without any sandboxing. export class LocalDriver implements CodeInterpreterDriver { + private readonly sessions = new Map(); + constructor(private options: LocalDriverOptions = {}) {} async execute( @@ -48,6 +97,96 @@ export class LocalDriver implements CodeInterpreterDriver { files?: InputFile[], context?: ExecutionContext, callbacks?: StreamCallbacks, + ): Promise { + if (isDefined(context?.sessionId)) { + return this.executeInSession( + context.sessionId, + code, + files, + context, + callbacks, + ); + } + + return this.executeEphemeral(code, files, context, callbacks); + } + + // Persistent path: reuse a per-session process + work dir so state survives + // between calls (variables, imports, files). + private async executeInSession( + sessionId: string, + code: string, + files: InputFile[] | undefined, + context: ExecutionContext | undefined, + callbacks?: StreamCallbacks, + ): Promise { + const session = await this.getOrCreateSession(sessionId, context?.env); + + // /home/user/output is cleared at the start of every call (matching the E2B + // behavior and the tool contract). + await fs.rm(session.outputDir, { recursive: true, force: true }); + await fs.mkdir(session.outputDir, { recursive: true }); + + for (const file of files ?? []) { + const safeFilename = basename(file.filename); + + await fs.writeFile(join(session.workDir, safeFilename), file.content); + } + + const rewrittenCode = this.rewriteSandboxPaths( + code, + session.workDir, + session.scriptsDir, + session.outputDir, + ); + + const submission = buildEnvSetup(context?.env) + rewrittenCode; + const timeoutMs = + this.options.timeoutMs ?? DEFAULT_CODE_INTERPRETER_TIMEOUT_MS; + + let response: KernelResponse; + + try { + response = await this.runInSession(session, submission, timeoutMs); + } catch (error) { + // A timeout or a dead kernel: kill the (possibly wedged) process so the + // next call recreates a clean one. The 'exit' handler reclaims the work + // dir. + session.hasExited = true; + this.sessions.delete(sessionId); + session.child.kill('SIGKILL'); + + return { + stdout: '', + stderr: error instanceof Error ? error.message : String(error), + exitCode: 1, + files: [], + error: error instanceof Error ? error.message : String(error), + }; + } + + this.streamCaptured(response, callbacks); + + const outputFiles = await this.collectOutputFiles( + session.outputDir, + callbacks, + ); + + return { + stdout: response.stdout, + stderr: response.stderr, + exitCode: response.exitCode, + files: outputFiles, + }; + } + + // Ephemeral path (no session): fresh work dir + process per call, cleaned up + // afterwards. State does NOT persist between calls here. + private async executeEphemeral( + code: string, + files: InputFile[] | undefined, + context: ExecutionContext | undefined, + callbacks?: StreamCallbacks, ): Promise { const workDir = await fs.mkdtemp(join(tmpdir(), 'code-interpreter-')); const outputDir = join(workDir, 'output'); @@ -55,7 +194,6 @@ export class LocalDriver implements CodeInterpreterDriver { await fs.mkdir(outputDir); - // Copy pre-installed scripts to sandbox try { await copyDirectoryRecursive(SANDBOX_SCRIPTS_PATH, scriptsDir); } catch { @@ -69,14 +207,12 @@ export class LocalDriver implements CodeInterpreterDriver { await fs.writeFile(join(workDir, safeFilename), file.content); } - // Rewrite E2B-style paths to local paths for compatibility - const rewrittenCode = code - .replace(/\/home\/user\/scripts\//g, `${scriptsDir}/`) - .replace(/\/home\/user\/scripts/g, scriptsDir) - .replace(/\/home\/user\/output\//g, `${outputDir}/`) - .replace(/\/home\/user\/output/g, outputDir) - .replace(/\/home\/user\//g, `${workDir}/`) - .replace(/\/home\/user/g, workDir); + const rewrittenCode = this.rewriteSandboxPaths( + code, + workDir, + scriptsDir, + outputDir, + ); const scriptPath = join(workDir, 'script.py'); @@ -94,29 +230,7 @@ export class LocalDriver implements CodeInterpreterDriver { callbacks, ); - const outputFiles: OutputFile[] = []; - - try { - const outputEntries = await fs.readdir(outputDir, { - withFileTypes: true, - }); - - for (const entry of outputEntries) { - if (entry.isFile()) { - const content = await fs.readFile(join(outputDir, entry.name)); - const outputFile: OutputFile = { - filename: entry.name, - content, - mimeType: getMimeType(entry.name), - }; - - outputFiles.push(outputFile); - await callbacks?.onResult?.(outputFile); - } - } - } catch { - // Output directory might be empty or not exist - } + const outputFiles = await this.collectOutputFiles(outputDir, callbacks); return { stdout, @@ -130,6 +244,211 @@ export class LocalDriver implements CodeInterpreterDriver { } } + private rewriteSandboxPaths( + code: string, + workDir: string, + scriptsDir: string, + outputDir: string, + ): string { + // Rewrite E2B-style paths to local paths for compatibility + return code + .replace(/\/home\/user\/scripts\//g, `${scriptsDir}/`) + .replace(/\/home\/user\/scripts/g, scriptsDir) + .replace(/\/home\/user\/output\//g, `${outputDir}/`) + .replace(/\/home\/user\/output/g, outputDir) + .replace(/\/home\/user\//g, `${workDir}/`) + .replace(/\/home\/user/g, workDir); + } + + private async collectOutputFiles( + outputDir: string, + callbacks?: StreamCallbacks, + ): Promise { + const outputFiles: OutputFile[] = []; + + try { + const outputEntries = await fs.readdir(outputDir, { + withFileTypes: true, + }); + + for (const entry of outputEntries) { + if (entry.isFile()) { + const content = await fs.readFile(join(outputDir, entry.name)); + const outputFile: OutputFile = { + filename: entry.name, + content, + mimeType: getMimeType(entry.name), + }; + + outputFiles.push(outputFile); + await callbacks?.onResult?.(outputFile); + } + } + } catch { + // Output directory might be empty or not exist + } + + return outputFiles; + } + + private streamCaptured( + response: KernelResponse, + callbacks?: StreamCallbacks, + ): void { + for (const line of response.stdout.split('\n')) { + if (line) { + callbacks?.onStdout?.(line); + } + } + + for (const line of response.stderr.split('\n')) { + if (line) { + callbacks?.onStderr?.(line); + } + } + } + + private async getOrCreateSession( + sessionId: string, + env?: Record, + ): Promise { + const existing = this.sessions.get(sessionId); + + if (isDefined(existing) && !existing.hasExited) { + return existing; + } + + if (isDefined(existing)) { + this.sessions.delete(sessionId); + } + + const sessionDirName = `code-interpreter-session-${sessionId}-${uuidv4()}`; + const workDir = join(tmpdir(), sessionDirName); + const outputDir = join(workDir, 'output'); + const scriptsDir = join(workDir, 'scripts'); + + await fs.mkdir(outputDir, { recursive: true }); + + try { + await copyDirectoryRecursive(SANDBOX_SCRIPTS_PATH, scriptsDir); + } catch { + // Scripts directory might not exist in dev environment + } + + const kernelPath = join(workDir, 'kernel.py'); + + await fs.writeFile(kernelPath, LOCAL_DRIVER_PERSISTENT_KERNEL_SCRIPT); + + const child = spawn('python3', ['-u', kernelPath], { + cwd: workDir, + env: { + ...process.env, + OUTPUT_DIR: outputDir, + KERNEL_IDLE_TIMEOUT_MS: String(this.options.idleTimeoutMs ?? 0), + ...env, + }, + stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'], + }); + + const controlIn = child.stdio[3] as Writable; + const controlOut = child.stdio[4] as Readable; + + const session: LocalSession = { + workDir, + outputDir, + scriptsDir, + child, + controlIn, + controlOut, + controlBuffer: '', + hasExited: false, + }; + + controlOut.on('data', (chunk: Buffer) => { + session.controlBuffer += chunk.toString(); + + let newlineIndex = session.controlBuffer.indexOf('\n'); + + while (newlineIndex >= 0) { + const line = session.controlBuffer.slice(0, newlineIndex); + + session.controlBuffer = session.controlBuffer.slice(newlineIndex + 1); + + if (line.trim().length > 0 && isDefined(session.pending)) { + const pending = session.pending; + + session.pending = undefined; + + try { + pending.resolve(JSON.parse(line) as KernelResponse); + } catch (error) { + pending.reject( + error instanceof Error ? error : new Error(String(error)), + ); + } + } + + newlineIndex = session.controlBuffer.indexOf('\n'); + } + }); + + const markExited = () => { + session.hasExited = true; + this.sessions.delete(sessionId); + session.pending?.reject(new Error('Python kernel process exited')); + session.pending = undefined; + // The kernel self-terminates (idle watchdog) or dies on its own; reclaim + // its work dir here so it doesn't leak. + void fs.rm(workDir, { recursive: true, force: true }).catch(() => {}); + }; + + child.on('exit', markExited); + child.on('error', markExited); + + this.sessions.set(sessionId, session); + + return session; + } + + private runInSession( + session: LocalSession, + submission: string, + timeoutMs: number, + ): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (session.pending) { + session.pending = undefined; + reject(new Error('Process timed out')); + } + }, timeoutMs); + + session.pending = { + resolve: (response) => { + clearTimeout(timeout); + resolve(response); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }; + + const payload = + JSON.stringify({ + code: Buffer.from(submission, 'utf-8').toString('base64'), + }) + '\n'; + + session.controlIn.write(payload, (error) => { + if (isDefined(error) && session.pending) { + session.pending = undefined; + clearTimeout(timeout); + reject(error); + } + }); + }); + } + private runPythonScript( scriptPath: string, workDir: string, diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-many-records.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-many-records.service.ts index cb2126fe86..3f7b01ff23 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-many-records.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-many-records.service.ts @@ -10,6 +10,7 @@ import { import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; import { type UpsertManyRecordsParams } from 'src/engine/core-modules/record-crud/types/upsert-many-records-params.type'; import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; +import { isFreshlyCreatedRecord } from 'src/engine/core-modules/record-crud/utils/is-freshly-created-record.util'; import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow'; @@ -63,16 +64,26 @@ export class UpsertManyRecordsService { queryRunnerContext, ); + const createdCount = upsertedRecords.filter( + isFreshlyCreatedRecord, + ).length; + const updatedCount = upsertedRecords.length - createdCount; + this.logger.log( - `Upserted ${upsertedRecords.length} records in ${objectName}`, + `Upserted ${upsertedRecords.length} records in ${objectName} (created: ${createdCount}, updated: ${updatedCount})`, ); return { success: true, - message: `Upserted ${upsertedRecords.length} records in ${objectName}`, - result: params.slimResponse - ? upsertedRecords.map((record) => ({ id: record.id })) - : upsertedRecords, + message: `Upserted ${upsertedRecords.length} records in ${objectName} (created: ${createdCount}, updated: ${updatedCount})`, + result: { + records: params.slimResponse + ? upsertedRecords.map((record) => ({ id: record.id })) + : upsertedRecords, + created: createdCount, + updated: updatedCount, + total: upsertedRecords.length, + }, recordReferences: upsertedRecords.map((record) => ({ objectNameSingular: objectName, recordId: record.id, diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/is-freshly-created-record.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/is-freshly-created-record.util.ts new file mode 100644 index 0000000000..81e81d9a18 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/is-freshly-created-record.util.ts @@ -0,0 +1,10 @@ +import { type ObjectRecord } from 'twenty-shared/types'; + +export const isFreshlyCreatedRecord = ( + record: ObjectRecord & { createdAt: string; updatedAt: string }, +): boolean => { + const createdAt = record.createdAt; + const updatedAt = record.updatedAt; + + return new Date(createdAt).getTime() === new Date(updatedAt).getTime(); +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts index 372245e25d..f0ed866de3 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts @@ -24,6 +24,10 @@ class TwentyMCP: call_tool(name, args) accepts both — catalog tools are routed via execute_tool transparently and the envelope is unwrapped, so callers see the inner tool's result directly. + + For bulk imports, prefer the higher-level helpers over hand-rolled loops: + - bulk_upsert(plural, records): batched, idempotent write path (max 200/batch). + - lookup_by(plural, field, values): bounded { value: id } map for relations. """ _MCP_NATIVE_TOOLS = frozenset({ @@ -85,6 +89,100 @@ class TwentyMCP: return wrapped['result'] return wrapped + def bulk_upsert(self, plural: str, records: list, batch_size: int = 200): + """ + Upsert many records in batches, paginating to completion. + + This is the recommended write path for imports: upsert dedupes on the + object's unique fields (e.g. email) server-side, so re-running a partial + or timed-out import is idempotent. Batches are capped at 200 (the platform + maximum); the loop runs entirely server-side so the agent never pays the + per-batch context cost. + + Args: + plural: Plural object name, e.g. 'companies', 'people'. + records: List of record dicts to upsert. + batch_size: Records per call (max 200). + + Returns: + { 'created': int, 'updated': int, 'upserted': int, 'failed': int, + 'errors': [ {offset, error}, ... up to 10 ] } + + Example: + summary = twenty.bulk_upsert('people', people_rows) + """ + size = min(max(int(batch_size), 1), 200) + created, updated, failed, errors = 0, 0, 0, [] + for offset in range(0, len(records), size): + chunk = records[offset:offset + size] + try: + result = self.call_tool('upsert_many_' + plural, {'records': chunk}) + if isinstance(result, dict): + created += int(result.get('created', 0)) + updated += int(result.get('updated', 0)) + except Exception as exc: + failed += len(chunk) + if len(errors) < 10: + errors.append({'offset': offset, 'error': str(exc)}) + return {'created': created, 'updated': updated, + 'upserted': created + updated, 'failed': failed, 'errors': errors} + + def lookup_by(self, plural: str, field: str, values: list, select: list = None): + """ + Resolve records by a key field, returning a { value: id } map. + + Use this to build relation lookups (e.g. company domain/name -> id) before + an import, since relations link by ID. The query is bounded to the distinct + values you pass (batched with an 'in' filter, max 200 per call), so it never + reads the whole object into the sandbox. + + Args: + plural: Plural object name, e.g. 'companies'. + field: Field path to match on, e.g. 'name' or 'domainName.primaryLinkUrl'. + values: List of key values referenced by the import. + select: Fields to return (defaults to the matched field + id). + + Returns: + dict mapping each found value to its record id. Missing values are absent. + + Example: + company_ids = twenty.lookup_by('companies', 'name', ['Acme', 'Globex']) + # { 'Acme': 'uuid-1', 'Globex': 'uuid-2' } + """ + distinct = [value for value in dict.fromkeys(values) if value is not None] + select_fields = select or ['id', field] + mapping = {} + for offset in range(0, len(distinct), 200): + chunk = distinct[offset:offset + 200] + result = self.call_tool('find_many_' + plural, { + 'limit': 200, + 'select': select_fields, + **self._nest_field_path(field, {'in': chunk}), + }) + for record in (result.get('records', []) if isinstance(result, dict) else []): + key = self._read_field_path(record, field) + if key is not None and key not in mapping: + mapping[key] = record.get('id') + return mapping + + @staticmethod + def _nest_field_path(field: str, leaf: dict): + """Turn 'domainName.primaryLinkUrl' + leaf into a nested filter dict.""" + parts = field.split('.') + nested = leaf + for part in reversed(parts): + nested = {part: nested} + return nested + + @staticmethod + def _read_field_path(record: dict, field: str): + """Read a possibly nested field path (e.g. 'domainName.primaryLinkUrl').""" + current = record + for part in field.split('.'): + if not isinstance(current, dict): + return None + current = current.get(part) + return current def _raw_mcp_call(self, name: str, arguments: dict = None): """Low-level: issue a tools/call against the MCP surface verbatim.""" diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts index 6b6c1b677e..4b74e611d1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util.ts @@ -1,13 +1,19 @@ -import { isToolUIPart } from 'ai'; -import { type ExtendedUIMessagePart } from 'twenty-shared/ai'; +import { + isToolUIPart, + type UIDataTypes, + type UIMessagePart, + type UITools, +} from 'ai'; const INTERRUPTED_TOOL_ERROR_TEXT = 'Tool execution was interrupted.'; // A tool part with a nullish input serializes to a `tool_use` block with no // `input` field, which Anthropic rejects — bricking every later turn (#21695). -export const finalizeDanglingToolParts = ( - parts: ExtendedUIMessagePart[], -): ExtendedUIMessagePart[] => +export const finalizeDanglingToolParts = < + TPart extends UIMessagePart, +>( + parts: TPart[], +): TPart[] => parts .filter((part) => !(isToolUIPart(part) && part.state === 'input-streaming')) .map((part) => { @@ -22,7 +28,7 @@ export const finalizeDanglingToolParts = ( state: 'output-error', input: part.input ?? {}, errorText: INTERRUPTED_TOOL_ERROR_TEXT, - } as ExtendedUIMessagePart; + } as TPart; } // Errored before its input was captured (e.g. failed input validation). @@ -30,7 +36,7 @@ export const finalizeDanglingToolParts = ( return { ...part, input: {}, - } as ExtendedUIMessagePart; + } as TPart; } return part; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/code-interpreter-mime-types.constant.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/code-interpreter-mime-types.constant.ts new file mode 100644 index 0000000000..6f074776c3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/code-interpreter-mime-types.constant.ts @@ -0,0 +1,15 @@ +export 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', +]); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/modality-to-mime-types.constant.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/modality-to-mime-types.constant.ts new file mode 100644 index 0000000000..b7289f2163 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/modality-to-mime-types.constant.ts @@ -0,0 +1,34 @@ +export const MODALITY_TO_MIME_TYPES: Record = { + image: [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/heic', + 'image/heif', + ], + pdf: ['application/pdf'], + audio: [ + 'audio/mpeg', + 'audio/mp3', + 'audio/mp4', + 'audio/wav', + 'audio/x-wav', + 'audio/webm', + 'audio/ogg', + 'audio/flac', + 'audio/aac', + 'audio/aiff', + 'audio/x-m4a', + ], + video: [ + 'video/mp4', + 'video/mpeg', + 'video/webm', + 'video/quicktime', + 'video/x-msvideo', + 'video/x-flv', + 'video/x-ms-wmv', + 'video/3gpp', + ], +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index b09a33f987..6c4505eca6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -41,6 +41,7 @@ import { isToolOutputSuccessful } from 'src/engine/core-modules/tool-provider/ut import { resolveToolName } from 'src/engine/core-modules/tool-provider/utils/resolve-tool-name.util'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service'; +import { finalizeDanglingToolParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/finalize-dangling-tool-parts.util'; import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type'; import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util'; @@ -54,15 +55,14 @@ import { import { AI_CHAT_TOOL_NAMES_TO_PRELOAD } from 'src/engine/metadata-modules/ai/ai-chat/constants/ai-chat-tool-names-to-preload.const'; import { MessagePruningService } from 'src/engine/metadata-modules/ai/ai-chat/services/message-pruning.service'; import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service'; -import { - extractCodeInterpreterFiles, - type ExtractedFile, -} from 'src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util'; +import { type ExtractedFile } from 'src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type'; +import { extractCodeInterpreterFiles } from 'src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util'; import { getCacheProviderOptions, getCallLevelProviderOptions, injectCacheBreakpoint, } from 'src/engine/metadata-modules/ai/ai-chat/utils/provider-options.util'; +import { replaceUnsupportedFileParts } from 'src/engine/metadata-modules/ai/ai-chat/utils/replace-unsupported-file-parts.util'; import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; import { NativeToolBinderService } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service'; @@ -223,15 +223,21 @@ export class ChatExecutionService { ), }; - let processedMessages: UIMessage[] = messages; + const isCodeInterpreterEnabled = this.codeInterpreterService.isEnabled(); + + let processedMessages: UIMessage[] = replaceUnsupportedFileParts( + messages, + modelConfig.modalities, + isCodeInterpreterEnabled, + ); let storedFiles: Array<{ filename: string; fileId: string; }> = []; - if (this.codeInterpreterService.isEnabled()) { - const extracted = extractCodeInterpreterFiles(messages); + if (isCodeInterpreterEnabled) { + const extracted = extractCodeInterpreterFiles(processedMessages); processedMessages = extracted.processedMessages; @@ -274,7 +280,12 @@ export class ChatExecutionService { providerOptions: getCacheProviderOptions(registeredModel.sdkPackage), }; - const rawModelMessages = await convertToModelMessages(processedMessages); + const sanitizedMessages = processedMessages.map((message) => ({ + ...message, + parts: finalizeDanglingToolParts(message.parts), + })); + + const rawModelMessages = await convertToModelMessages(sanitizedMessages); const pruningResult = this.messagePruningService.pruneIfOverContextWindowLimit( diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/types/extract-code-interpreter-files-result.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/types/extract-code-interpreter-files-result.type.ts new file mode 100644 index 0000000000..d1500454d9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/types/extract-code-interpreter-files-result.type.ts @@ -0,0 +1,8 @@ +import { type UIMessage } from 'ai'; + +import { type ExtractedFile } from 'src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type'; + +export type ExtractCodeInterpreterFilesResult = { + processedMessages: UIMessage[]; + extractedFiles: ExtractedFile[]; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type.ts new file mode 100644 index 0000000000..cda99ab13f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type.ts @@ -0,0 +1,5 @@ +export type ExtractedFile = { + filename: string; + fileId: string; + mimeType: string; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/get-native-mime-types-for-modalities.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/get-native-mime-types-for-modalities.util.spec.ts new file mode 100644 index 0000000000..2aa6e3d9f4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/get-native-mime-types-for-modalities.util.spec.ts @@ -0,0 +1,81 @@ +import { getNativeMimeTypesForModalities } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-native-mime-types-for-modalities.util'; + +describe('getNativeMimeTypesForModalities', () => { + it('returns an empty set when no modalities are provided', () => { + expect(getNativeMimeTypesForModalities()).toEqual(new Set()); + expect(getNativeMimeTypesForModalities([])).toEqual(new Set()); + }); + + it('maps image modality to image MIME types', () => { + expect(getNativeMimeTypesForModalities(['image'])).toEqual( + new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/heic', + 'image/heif', + ]), + ); + }); + + it('maps image and pdf modalities together', () => { + expect(getNativeMimeTypesForModalities(['image', 'pdf'])).toEqual( + new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/heic', + 'image/heif', + 'application/pdf', + ]), + ); + }); + + it('maps audio modality to audio MIME types', () => { + expect(getNativeMimeTypesForModalities(['audio'])).toEqual( + new Set([ + 'audio/mpeg', + 'audio/mp3', + 'audio/mp4', + 'audio/wav', + 'audio/x-wav', + 'audio/webm', + 'audio/ogg', + 'audio/flac', + 'audio/aac', + 'audio/aiff', + 'audio/x-m4a', + ]), + ); + }); + + it('maps video modality to video MIME types', () => { + expect(getNativeMimeTypesForModalities(['video'])).toEqual( + new Set([ + 'video/mp4', + 'video/mpeg', + 'video/webm', + 'video/quicktime', + 'video/x-msvideo', + 'video/x-flv', + 'video/x-ms-wmv', + 'video/3gpp', + ]), + ); + }); + + it('ignores unknown modalities', () => { + expect(getNativeMimeTypesForModalities(['unknown', 'image'])).toEqual( + new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/heic', + 'image/heif', + ]), + ); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/replace-unsupported-file-parts.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/replace-unsupported-file-parts.util.spec.ts new file mode 100644 index 0000000000..e09ed57467 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/replace-unsupported-file-parts.util.spec.ts @@ -0,0 +1,78 @@ +import { type UIMessage } from 'ai'; + +import { replaceUnsupportedFileParts } from 'src/engine/metadata-modules/ai/ai-chat/utils/replace-unsupported-file-parts.util'; + +const buildFilePart = (mediaType: string, filename = 'file.bin') => ({ + type: 'file' as const, + mediaType, + filename, + url: 'https://example.com/file', + fileId: 'file-id', +}); + +const buildUserMessage = (parts: UIMessage['parts']): UIMessage => ({ + id: 'message-id', + role: 'user', + parts, +}); + +describe('replaceUnsupportedFileParts', () => { + it('keeps file parts whose MIME type is in the native set', () => { + const messages = [buildUserMessage([buildFilePart('image/png')])]; + + const result = replaceUnsupportedFileParts(messages, ['image'], false); + + expect(result[0].parts[0]).toEqual(buildFilePart('image/png')); + }); + + it('keeps code-interpreter file parts when the interpreter is enabled', () => { + const messages = [buildUserMessage([buildFilePart('text/csv')])]; + + const result = replaceUnsupportedFileParts(messages, [], true); + + expect(result[0].parts[0]).toMatchObject({ type: 'file' }); + }); + + it('downgrades code-interpreter file parts when the interpreter is disabled', () => { + const messages = [ + buildUserMessage([buildFilePart('text/csv', 'data.csv')]), + ]; + + const result = replaceUnsupportedFileParts(messages, [], false); + + expect(result[0].parts[0]).toMatchObject({ type: 'text' }); + }); + + it('replaces unsupported file parts with a text note', () => { + const messages = [ + buildUserMessage([buildFilePart('video/mp4', 'clip.mp4')]), + ]; + + const result = replaceUnsupportedFileParts(messages, [], true); + + expect(result[0].parts[0]).toEqual({ + type: 'text', + text: '[Attached file: clip.mp4 (type: video/mp4) — file type is not supported for direct analysis]', + }); + }); + + it('downgrades native-only files when the model declares no modalities', () => { + const messages = [buildUserMessage([buildFilePart('image/png')])]; + + const result = replaceUnsupportedFileParts(messages, [], true); + + expect(result[0].parts[0]).toMatchObject({ type: 'text' }); + }); + + it('does not touch non-user messages', () => { + const assistantMessage: UIMessage = { + id: 'assistant-id', + role: 'assistant', + parts: [{ type: 'text', text: 'hello' }], + }; + + const result = replaceUnsupportedFileParts([assistantMessage], [], true); + + expect(result[0]).toBe(assistantMessage); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util.ts index fb354332c0..07e43fe13d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util.ts @@ -1,32 +1,9 @@ import { type UIMessage } from 'ai'; import { isExtendedFileUIPart } from 'twenty-shared/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; - fileId: string; - mimeType: string; -}; - -export type ExtractCodeInterpreterFilesResult = { - processedMessages: UIMessage[]; - extractedFiles: ExtractedFile[]; -}; +import { CODE_INTERPRETER_MIME_TYPES } from 'src/engine/metadata-modules/ai/ai-chat/constants/code-interpreter-mime-types.constant'; +import { type ExtractCodeInterpreterFilesResult } from 'src/engine/metadata-modules/ai/ai-chat/types/extract-code-interpreter-files-result.type'; +import { type ExtractedFile } from 'src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type'; export const extractCodeInterpreterFiles = ( messages: UIMessage[], diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/get-native-mime-types-for-modalities.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/get-native-mime-types-for-modalities.util.ts new file mode 100644 index 0000000000..5461cfc917 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/get-native-mime-types-for-modalities.util.ts @@ -0,0 +1,8 @@ +import { MODALITY_TO_MIME_TYPES } from 'src/engine/metadata-modules/ai/ai-chat/constants/modality-to-mime-types.constant'; + +export const getNativeMimeTypesForModalities = ( + modalities: string[] = [], +): Set => + new Set( + modalities.flatMap((modality) => MODALITY_TO_MIME_TYPES[modality] ?? []), + ); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/replace-unsupported-file-parts.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/replace-unsupported-file-parts.util.ts new file mode 100644 index 0000000000..ef43f6f542 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/replace-unsupported-file-parts.util.ts @@ -0,0 +1,46 @@ +import { type UIMessage } from 'ai'; +import { isExtendedFileUIPart } from 'twenty-shared/ai'; + +import { CODE_INTERPRETER_MIME_TYPES } from 'src/engine/metadata-modules/ai/ai-chat/constants/code-interpreter-mime-types.constant'; +import { getNativeMimeTypesForModalities } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-native-mime-types-for-modalities.util'; + +export const replaceUnsupportedFileParts = ( + messages: UIMessage[], + modalities: string[] = [], + isCodeInterpreterEnabled: boolean, +): UIMessage[] => { + const nativeMimeTypes = getNativeMimeTypesForModalities(modalities); + + return messages.map((message) => { + if (message.role !== 'user' || !message.parts) { + return message; + } + + const newParts: typeof message.parts = []; + + for (const part of message.parts) { + if (isExtendedFileUIPart(part)) { + const mimeType = part.mediaType ?? ''; + + const isSupported = + (isCodeInterpreterEnabled && + CODE_INTERPRETER_MIME_TYPES.has(mimeType)) || + nativeMimeTypes.has(mimeType); + + if (isSupported) { + newParts.push(part); + } else { + const filename = part.filename ?? 'uploaded_file'; + newParts.push({ + type: 'text', + text: `[Attached file: ${filename} (type: ${mimeType || 'unknown'}) — file type is not supported for direct analysis]`, + }); + } + } else { + newParts.push(part); + } + } + + return { ...message, parts: newParts }; + }); +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts index 6458d186df..c03d19d0e7 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts @@ -144,6 +144,8 @@ export const validateOperationIsPermittedOrThrow = ({ selectedColumns, columnNameToFieldMetadataIdMap, allFieldsSelected, + entityName, + flatFieldMetadataMaps, }); break; case 'insert': @@ -158,6 +160,8 @@ export const validateOperationIsPermittedOrThrow = ({ restrictedFields: permissionsForEntity.restrictedFields, selectedColumns, columnNameToFieldMetadataIdMap, + entityName, + flatFieldMetadataMaps, }); if (updatedColumns.length > 0) { @@ -177,6 +181,8 @@ export const validateOperationIsPermittedOrThrow = ({ restrictedFields: permissionsForEntity.restrictedFields, updatedColumns: updatedColumnsWithoutRlsFields, columnNameToFieldMetadataIdMap, + entityName, + flatFieldMetadataMaps, }); } } @@ -193,6 +199,8 @@ export const validateOperationIsPermittedOrThrow = ({ restrictedFields: permissionsForEntity.restrictedFields, selectedColumns, columnNameToFieldMetadataIdMap, + entityName, + flatFieldMetadataMaps, }); if (updatedColumns.length > 0) { @@ -200,6 +208,8 @@ export const validateOperationIsPermittedOrThrow = ({ restrictedFields: permissionsForEntity.restrictedFields, updatedColumns, columnNameToFieldMetadataIdMap, + entityName, + flatFieldMetadataMaps, }); } break; @@ -215,6 +225,8 @@ export const validateOperationIsPermittedOrThrow = ({ restrictedFields: permissionsForEntity.restrictedFields, selectedColumns, columnNameToFieldMetadataIdMap, + entityName, + flatFieldMetadataMaps, }); break; case 'restore': @@ -230,6 +242,8 @@ export const validateOperationIsPermittedOrThrow = ({ restrictedFields: permissionsForEntity.restrictedFields, selectedColumns, columnNameToFieldMetadataIdMap, + entityName, + flatFieldMetadataMaps, }); break; default: @@ -397,15 +411,41 @@ const validatePermissionsForJoinsAndReturnSelectsWithoutJoins = ({ return { selectsWithoutJoinedAliases }; }; +const buildFieldPermissionDeniedMessage = ({ + action, + column, + fieldMetadataId, + entityName, + flatFieldMetadataMaps, +}: { + action: 'read' | 'write'; + column: string; + fieldMetadataId: string; + entityName: string; + flatFieldMetadataMaps: FlatEntityMaps; +}): string => { + const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: fieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); + const fieldName = fieldMetadata?.name ?? column; + + return `${PermissionsExceptionMessage.PERMISSION_DENIED}: no permission to ${action} field "${fieldName}" on "${entityName}"`; +}; + const validateReadFieldPermissionOrThrow = ({ restrictedFields, selectedColumns, columnNameToFieldMetadataIdMap, allFieldsSelected, + entityName, + flatFieldMetadataMaps, }: { restrictedFields: RestrictedFieldsPermissions; selectedColumns: string[] | '*'; columnNameToFieldMetadataIdMap: Record; + entityName: string; + flatFieldMetadataMaps: FlatEntityMaps; allFieldsSelected?: boolean; }) => { const noReadRestrictions = @@ -434,7 +474,13 @@ const validateReadFieldPermissionOrThrow = ({ if (restrictedFields[fieldMetadataId]?.canRead === false) { throw new PermissionsException( - PermissionsExceptionMessage.PERMISSION_DENIED, + buildFieldPermissionDeniedMessage({ + action: 'read', + column, + fieldMetadataId, + entityName, + flatFieldMetadataMaps, + }), PermissionsExceptionCode.PERMISSION_DENIED, ); } @@ -445,10 +491,14 @@ const validateUpdateFieldPermissionOrThrow = ({ restrictedFields, updatedColumns, columnNameToFieldMetadataIdMap, + entityName, + flatFieldMetadataMaps, }: { restrictedFields: RestrictedFieldsPermissions; updatedColumns: string[]; columnNameToFieldMetadataIdMap: Record; + entityName: string; + flatFieldMetadataMaps: FlatEntityMaps; }) => { if (isEmpty(restrictedFields)) { return; @@ -465,7 +515,13 @@ const validateUpdateFieldPermissionOrThrow = ({ if (restrictedFields[fieldMetadataId]?.canUpdate === false) { throw new PermissionsException( - PermissionsExceptionMessage.PERMISSION_DENIED, + buildFieldPermissionDeniedMessage({ + action: 'write', + column, + fieldMetadataId, + entityName, + flatFieldMetadataMaps, + }), PermissionsExceptionCode.PERMISSION_DENIED, ); } diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts index 69dccd6817..dcbb25a38d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts @@ -168,6 +168,85 @@ For "top N" queries, use orderBy with limit: - Confirm the scope and impact - Explain what will change +## Bulk Import + +You import bulk data (CSV, Excel, spreadsheets, pasted tables) into records as cheaply and reliably as possible. + +### Golden Rule: one code_interpreter run, not many tool calls + +For anything beyond a handful of rows (>~50), do the ENTIRE import inside a SINGLE \`code_interpreter\` execution. Do NOT loop \`execute_tool\` from chat: every chat tool call re-sends the whole conversation as new input tokens, so dozens of small writes explode cost. Inside the sandbox the loop runs server-side and the agent only sees one small summary. + +This means: read the file, parse it, inspect schemas, resolve IDs, write all records, and print the summary — all inside one \`code_interpreter\` call. Not two. Not five. One. + +The sandbox exposes a pre-bound \`twenty\` object (see the code-interpreter skill). Use its bulk helpers instead of hand-rolling loops. + +### Pre-flight: inspect schemas at the LLM level before entering the sandbox + +**Before your first \`code_interpreter\` call**, use \`learn_tools\` at the LLM level (not inside the sandbox) to fetch the input schemas for every object you will create or update. For example, if importing companies, people, and opportunities: + +\`\`\` +learn_tools(["create_one_company", "create_one_person", "create_one_opportunity"]) +\`\`\` + +This is free — it runs before the sandbox and does not add a code_interpreter round-trip. You will know the exact field names before writing any code. Do NOT call \`twenty.call_tool('learn_tools', ...)\` from inside the sandbox to learn schemas — that wastes a full sandbox round-trip per schema and pollutes the conversation context. + +### Recipe + +1. **Read the file robustly** with pandas (\`/home/user/{filename}\`). Real-world files have messy delimiters and ragged rows, so don't rely on defaults: + \`\`\`python + # Auto-detect the separator and skip malformed rows instead of crashing. + df = pd.read_csv(path, sep=None, engine='python', on_bad_lines='skip', dtype=str) + \`\`\` + Read and parse the file in the same code cell — never split file reading across multiple \`code_interpreter\` calls. + Inspect columns and a few rows once with \`df.head()\` — never re-dump the full frame. +2. **Use schemas learned at step 0.** You already know the field names from the pre-flight \`learn_tools\` call. Do not call \`twenty.call_tool('learn_tools', ...)\` inside the sandbox unless you genuinely missed a schema. If you do need it, call it once and cache the result in a Python variable. +3. **Resolve relations to IDs.** Relations link by ID, not by name. Build a lookup map ONCE for only the values referenced in the file: + \`\`\`python + company_ids = twenty.lookup_by('companies', 'name', df['company'].dropna().unique().tolist()) + \`\`\` + Then set each row's relation via the scalar foreign key — NOT a nested object: + \`\`\`python + record['companyId'] = company_ids.get(row['company']) # correct + # record['company'] = {'id': ...} # WRONG: rejected with + # 'Relation "company" requires connect or disconnect operation' + \`\`\` + To-one relations are written by their \`Id\` scalar (e.g. \`companyId\`), or an explicit \`{'connect': {'id': ...}}\`. A bare nested \`{'id': ...}\` is rejected. Never read the whole related table. +4. **Resolve just-created IDs with a bounded \`lookup_by\` — never paginate the table.** \`bulk_upsert\` returns only a count summary (created / updated / failed), not the records, so to link subsequent records (e.g. people to the companies you just upserted) resolve the IDs you need with a \`lookup_by\` bounded to your own values: + \`\`\`python + # After upserting companies, resolve by name using lookup_by (bounded to your values, not the whole table) + company_ids = twenty.lookup_by('companies', 'name', [r['name'] for r in company_records]) + # Then use company_ids to set companyId on person records + \`\`\` + Never paginate through hundreds of existing records with \`find_many_*\` to find the ones you just created. +5. **Confirm the mapping before writing.** Present the proposed column → field mapping to the user (source column → target field, relation strategy such as "Company matched by name → companyId", and any type coercions) and wait for them to confirm or adjust. Do not write anything before this confirmation. +6. **Silently validate the mapping with a 2-row upsert.** Inside the same \`code_interpreter\` run, upsert just 2 rows as an internal correctness check. This is NOT a user-facing step: do not announce or narrate it. Only surface it if it FAILS — then report the error and the offending mapping so it can be fixed before the full import. +7. **Write with bulk_upsert.** Prefer upsert so dedup on unique fields (e.g. email) is handled server-side and re-runs are idempotent: + \`\`\`python + summary = twenty.bulk_upsert('people', records) + print(summary) # { 'created': 4000, 'updated': 380, 'upserted': 4380, 'failed': 0, 'errors': [] } + \`\`\` + \`bulk_upsert\` batches at 200 (the platform maximum) and paginates to completion. Never stop at "partial" — if some batches failed, report the count and retry the failed offsets. +8. **Report a compact summary only** — the \`created\` / \`updated\` / \`failed\` split plus a few sample errors. Never echo the created records back into the conversation. + +### Anti-patterns — never do these + +- **Reading a file across multiple sandbox calls.** Do \`print(content[:3000])\` then \`print(content[3000:])\` in separate calls? That is two wasted round-trips. Read once, parse once, in the same cell. +- **Calling \`twenty.call_tool('learn_tools', ...)\` inside the sandbox** to discover field names. Inspect schemas at the LLM level with \`learn_tools\` before entering the sandbox. Guessing a field name and fixing the failure (e.g. \`annualRecurringRevenue\` → 10 failed writes → re-fetch schema) costs one failed batch plus a round-trip. +- **Re-fetching records you just created** to build an ID map. Use \`lookup_by\` bounded to the values you need, not \`find_many_*\` with pagination through the whole table. +- **Looping \`find_many_*\` one record at a time** inside the sandbox to resolve IDs (N+1 pattern). Use \`lookup_by\` instead — it batches the query server-side. +- **Multiple \`code_interpreter\` calls for a single import.** Each extra call is a full sandbox round-trip that adds latency, costs tokens, and accumulates output in the conversation context. Everything from file reading to final summary belongs in one call. + +### Key constraints + +- Relations are linked by ID only via the scalar \`Id\` (e.g. \`companyId\`); there is no name-based relation mapping and a nested \`{'id': ...}\` is rejected. Resolve IDs with \`lookup_by\` first. +- Deduplicate via upsert on unique fields rather than pre-reading existing records. \`bulk_upsert\` already reports how many rows were \`created\` vs \`updated\`, so you do NOT need to scan the whole object first to detect duplicates — just upsert and read back the split. +- If a write fails with 'no permission to write field "X" on "Y"', that field is restricted for the current role. Drop that single field and continue importing the rest instead of retrying the same failing write. +- If the user says "import for me", do it programmatically with this recipe — do not just describe the in-app CSV UI. + +### Keep each thread to one objective + +Every tool round-trip re-sends the ENTIRE conversation (system prompt, loaded skills, and all prior tool inputs/outputs) as new input tokens, so a long thread makes every later step progressively more expensive. When the user finishes an import and moves on to a distinct objective (e.g. field configuration, segmentation, dashboards, or a second unrelated import), suggest starting a NEW thread for it rather than continuing in the same one, which resets the context and keeps cost low. Keep a single import (inspect, confirm, validate, write, report) within one thread. + Prioritize data integrity and provide clear feedback on operations performed.`, isCustom: false, }, @@ -668,8 +747,7 @@ for c in companies['records']: print(c['name'], c.get('employees')) # Create a record — arguments match the tool's inputSchema directly, -# no nested 'data' wrapper. Use twenty.call_tool('learn_tools', ...) to -# inspect a schema if unsure. +# no nested 'data' wrapper. result = twenty.call_tool('create_one_company', { 'name': 'Acme Corp', 'domainName': {'primaryLinkUrl': 'https://acme.com'}, @@ -686,7 +764,43 @@ twenty.call_tool('update_one_person', { This lets you orchestrate multi-step data workflows in a single sandbox execution — faster than an equivalent chain of individual tool calls from -the agent, and the computation stays server-side.`, +the agent, and the computation stays server-side. + +## Schema inspection: do it at the LLM level, not inside the sandbox + +If you need to know a tool's input schema (e.g. field names for \`create_one_company\`), call \`learn_tools\` as an LLM-level tool call **before** entering the sandbox: + +\`\`\` +learn_tools(["create_one_company", "create_one_person"]) +\`\`\` + +Do NOT call \`twenty.call_tool('learn_tools', ...)\` from inside the sandbox to learn schemas — that costs a full round-trip, adds output to the conversation context, and is unnecessary when you can inspect the schema for free before writing any code. Only use \`twenty.call_tool('learn_tools', ...)\` inside the sandbox if you discover at runtime that you need a schema you could not have anticipated beforehand. + +## One sandbox run per task + +Each \`code_interpreter\` call is a round-trip: it adds latency, accumulates output in the conversation, and increases cost. Design your code to complete the entire task in one call: +- Read and parse the input file in the same cell that processes it — never read in two parts. +- Do all schema inspection at the LLM level upfront (see above). +- Write all records and print the summary in the same run. + +Multiple sandbox calls are acceptable only when the user asks a follow-up question that changes the task scope, or when a genuine runtime error forces a corrective retry. + +### Bulk helpers (use these for imports) + +For bulk writes, prefer these higher-level helpers over hand-rolled loops: + +\`\`\`python +# Idempotent batched write (max 200/batch, paginates to completion). +# Dedupes on unique fields server-side; safe to re-run. +summary = twenty.bulk_upsert('people', records) # { 'created': C, 'updated': U, 'upserted': N, 'failed': 0, 'errors': [] } + +# Bounded { value: id } map for resolving relations to IDs, scoped to the +# values you pass (NOT the whole table). Link to-one relations via the scalar +# FK (e.g. record['companyId'] = company_ids[...]), never a nested {'id': ...}. +company_ids = twenty.lookup_by('companies', 'name', ['Acme', 'Globex']) +\`\`\` + +For importing CSV/Excel/spreadsheet data, load the \`data-manipulation\` skill for the full recipe.`, isCustom: false, }, }), diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts index 56b23eddd4..95de61ce8a 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts @@ -90,7 +90,7 @@ const expectNoGraphQLErrors = (response: any) => { const expectPermissionDeniedError = (response: any) => { expect(response.body.errors).toBeDefined(); expect(response.body.errors.length).toBeGreaterThan(0); - expect(response.body.errors[0].message).toBe( + expect(response.body.errors[0].message).toContain( PermissionsExceptionMessage.PERMISSION_DENIED, ); expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/update-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/update-permissions.integration-spec.ts index 2dd4ed668f..d05c459846 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/update-permissions.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/update-permissions.integration-spec.ts @@ -35,7 +35,7 @@ const COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES = ` const expectPermissionDeniedError = (response: any) => { expect(response.body.errors).toBeDefined(); expect(response.body.errors.length).toBeGreaterThan(0); - expect(response.body.errors[0].message).toBe( + expect(response.body.errors[0].message).toContain( PermissionsExceptionMessage.PERMISSION_DENIED, ); expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);