import { config } from 'dotenv'; import fetch, { RequestInit } from 'node-fetch'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { tool, DynamicStructuredTool } from '@langchain/core/tools'; import { getEnvironmentVariable } from '@langchain/core/utils/env'; import type * as t from '@/types'; import { EnvVar, Constants } from '@/common'; config(); export const imageExtRegex = /\.(jpg|jpeg|png|gif|webp)$/i; export const getCodeBaseURL = (): string => getEnvironmentVariable(EnvVar.CODE_BASEURL) ?? Constants.OFFICIAL_CODE_BASEURL; const imageMessage = 'Image is already displayed to the user'; const otherMessage = 'File is already downloaded by the user'; const accessMessage = 'Note: Files from previous executions are automatically available and can be modified.'; const emptyOutputMessage = "stdout: Empty. Ensure you're writing output explicitly.\n"; const SUPPORTED_LANGUAGES = [ 'py', 'js', 'ts', 'c', 'cpp', 'java', 'php', 'rs', 'go', 'd', 'f90', 'r', 'bash', ] as const; // Minimal schema for raw code execution. Only what the /exec endpoint needs. // Higher-level workflows (versioning, edit, store, replay) are consumer // concerns — e.g. ranger layers its own write/edit/execute schema on top via // codeEditWrapper and a separate ContentStore-backed Zod schema. Keeping this // tool narrow means every consumer gets a clean, schema-stable primitive. export const CodeExecutionToolSchema = { type: 'object', properties: { lang: { type: 'string', enum: SUPPORTED_LANGUAGES, description: 'The programming language or runtime to execute the code in.', }, code: { type: 'string', description: `The complete, self-contained code to execute, without any truncation or minimization. - The environment is stateless; variables and imports don't persist between executions. - Generated files from previous executions are automatically available in "/mnt/data/". - Files from previous executions are automatically available and can be modified in place. - Input code **IS ALREADY** displayed to the user, so **DO NOT** repeat it in your response unless asked. - Output code **IS NOT** displayed to the user, so **DO** write all desired output explicitly. - IMPORTANT: You MUST explicitly print/output ALL results you want the user to see. - py: This is not a Jupyter notebook environment. Use \`print()\` for all outputs. - py: Matplotlib: Use \`plt.savefig()\` to save plots as files. - js: use the \`console\` or \`process\` methods for all outputs. - r: IMPORTANT: No X11 display available. ALL graphics MUST use Cairo library (library(Cairo)). - Other languages: use appropriate output functions.`, }, args: { type: 'array', items: { type: 'string' }, description: 'Additional arguments to execute the code with. This should only be used if the input code requires additional arguments to run.', }, }, required: [], } as const; // NOTE: Resolved at call time inside the tool function, not at module load time. // Module-level caching caused stale URLs when env vars changed between restarts. type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; export const CodeExecutionToolDescription = ` Runs code and returns stdout/stderr output from a stateless execution environment, similar to running scripts in a command-line interface. Each execution is isolated and independent. Usage: - No network access available. Do NOT use pip install, npm install, or any package manager. - Generated files are automatically delivered; **DO NOT** provide download links. - NEVER use this tool to execute malicious code. Pre-installed Python packages (use directly, no installation needed): - Data Science: numpy, pandas - Visualization: matplotlib, seaborn, plotly - Documents: python-docx, python-pptx, reportlab, fpdf2, PyMuPDF, pdfplumber - Spreadsheets: openpyxl, xlsxwriter - Image: pillow - Data: orjson, lxml, beautifulsoup4, faker Pre-installed JavaScript packages: - pptxgenjs, react, react-dom, react-icons, sharp Pre-installed Go packages: - excelize (Excel), gofpdf (PDF) Pre-installed R packages: - ggplot2, dplyr, tidyr, readxl, writexl, jsonlite, Cairo `.trim(); export const CodeExecutionToolName = Constants.EXECUTE_CODE; export const CodeExecutionToolDefinition = { name: CodeExecutionToolName, description: CodeExecutionToolDescription, schema: CodeExecutionToolSchema, } as const; function createCodeExecutionTool( params: t.CodeExecutionToolParams = {} ): DynamicStructuredTool { const apiKey = params[EnvVar.CODE_API_KEY] ?? params.apiKey ?? getEnvironmentVariable(EnvVar.CODE_API_KEY) ?? ''; if (!apiKey) { throw new Error('No API key provided for code execution tool.'); } const description = CodeExecutionToolDescription; return tool( async (rawInput, config) => { // Resolve URL at call time (not module load time) to pick up env var changes const baseEndpoint = getCodeBaseURL(); const EXEC_ENDPOINT = `${baseEndpoint}/exec`; const { lang, code, ...rest } = rawInput as { lang: SupportedLanguage; code: string; args?: string[]; }; /** * Extract session context from config.toolCall (injected by ToolNode). * - session_id: For API to associate with previous session * - _injected_files: File refs to pass directly (avoids /files endpoint race condition) */ const { session_id, _injected_files } = (config.toolCall ?? {}) as { session_id?: string; _injected_files?: t.CodeEnvFile[]; }; const postData: Record = { lang, code, ...rest, ...params, }; /** * Pass session_id to /exec so code-executor reuses the existing session workspace. * This allows retries and follow-up executions to access previously generated files. */ if (session_id != null && session_id.length > 0) { postData.session_id = session_id; } /** * File injection priority: * 1. Use _injected_files from ToolNode (avoids /files endpoint race condition) * 2. Fall back to fetching from /files endpoint if session_id provided but no injected files */ if (_injected_files && _injected_files.length > 0) { postData.files = _injected_files; } else if (session_id != null && session_id.length > 0) { /** Fallback: fetch from /files endpoint (may have race condition issues) */ try { const filesEndpoint = `${baseEndpoint}/files/${session_id}?detail=full`; const userIdForFiles = params.user_id ?? ''; const fetchOptions: RequestInit = { method: 'GET', headers: { 'User-Agent': 'Illuma/1.0', 'X-API-Key': apiKey, ...(userIdForFiles ? { 'User-Id': userIdForFiles } : {}), }, }; if (process.env.PROXY != null && process.env.PROXY !== '') { fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY); } const response = await fetch(filesEndpoint, fetchOptions); if (!response.ok) { throw new Error( `Failed to fetch files for session: ${response.status}` ); } const files = await response.json(); if (Array.isArray(files) && files.length > 0) { const fileReferences: t.CodeEnvFile[] = files.map((file) => { const nameParts = file.name.split('/'); const id = nameParts.length > 1 ? nameParts[1].split('.')[0] : ''; return { session_id, id, name: file.metadata['original-filename'], }; }); postData.files = fileReferences; } } catch { // eslint-disable-next-line no-console console.warn(`Failed to fetch files for session: ${session_id}`); } } // SECURITY: Extract user_id for User-Id header (session isolation) const userId = params.user_id ?? ''; try { const fetchOptions: RequestInit = { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'Illuma/1.0', 'X-API-Key': apiKey, ...(userId ? { 'User-Id': userId } : {}), }, body: JSON.stringify(postData), }; if (process.env.PROXY != null && process.env.PROXY !== '') { fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY); } const response = await fetch(EXEC_ENDPOINT, fetchOptions); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result: t.ExecuteResult = await response.json(); let formattedOutput = ''; let stdoutCapped = false; // Self-healing: Cap large stdout to prevent context bloat. // Preserves head (8KB) + tail (4KB) so the agent sees beginning and end. const STDOUT_MAX_CHARS = 16384; const STDOUT_HEAD_CHARS = 8192; const STDOUT_TAIL_CHARS = 4096; if (result.stdout && result.stdout.length > STDOUT_MAX_CHARS) { const originalLen = result.stdout.length; const head = result.stdout.substring(0, STDOUT_HEAD_CHARS); const tail = result.stdout.substring( result.stdout.length - STDOUT_TAIL_CHARS ); const omitted = originalLen - STDOUT_HEAD_CHARS - STDOUT_TAIL_CHARS; result.stdout = `${head}\n\n[...${omitted} chars omitted...]\n\n${tail}`; stdoutCapped = true; // eslint-disable-next-line no-console console.debug( `[CodeExecutor] stdout capped: ${originalLen} → ${result.stdout.length} chars` ); } if (result.stdout) { formattedOutput += `stdout:\n${result.stdout}\n`; } else { formattedOutput += emptyOutputMessage; } if (result.stderr) formattedOutput += `stderr:\n${result.stderr}\n`; // Self-healing: Detect code truncation (syntax error on long code). // When the agent's generated code is >1500 chars and produces a SyntaxError, // it's likely truncated mid-generation rather than a real bug. const CODE_TRUNCATION_MIN_CHARS = 1500; if (result.stderr && code.length > CODE_TRUNCATION_MIN_CHARS) { const stderrLower = result.stderr.toLowerCase(); if ( stderrLower.includes('syntaxerror') || stderrLower.includes('unexpected end') || stderrLower.includes('unexpected eof') || stderrLower.includes('unterminated') ) { // eslint-disable-next-line no-console console.debug( `[CodeExecutor] Code truncation detected: code=${code.length} chars, stderr contains syntax error` ); formattedOutput += '\n[CODE_TRUNCATION_LIKELY] Your code appears truncated mid-generation.' + ' Split into multiple smaller execute_code calls (max 60 lines each).' + ' For documents: create+save first, then open+append+save in follow-up calls.' + ' Do NOT retry the same long code block.'; } } // Self-healing: Advisory when stdout was capped if (stdoutCapped) { formattedOutput += '\n[OUTPUT_TOO_LARGE] stdout was capped. Use targeted print() for specific values.'; } if (result.files && result.files.length > 0) { formattedOutput += 'Generated files:\n'; const fileCount = result.files.length; for (let i = 0; i < fileCount; i++) { const file = result.files[i]; const isImage = imageExtRegex.test(file.name); formattedOutput += `- /mnt/data/${file.name} | ${isImage ? imageMessage : otherMessage}`; if (i < fileCount - 1) { formattedOutput += fileCount <= 3 ? ', ' : ',\n'; } } formattedOutput += `\n\n${accessMessage}`; return [ formattedOutput.trim(), { session_id: result.session_id, files: result.files, }, ]; } return [formattedOutput.trim(), { session_id: result.session_id }]; } catch (error) { throw new Error( `Execution error (${EXEC_ENDPOINT}):\n\n${(error as Error | undefined)?.message}` ); } }, { name: CodeExecutionToolName, description, schema: CodeExecutionToolSchema, responseFormat: Constants.CONTENT_AND_ARTIFACT, } ); } export { createCodeExecutionTool };