├── .env.example ├── src ├── tool-results-compactor │ ├── index.ts │ ├── lib │ │ ├── resolver.ts │ │ └── grep.ts │ ├── compact.ts │ └── strategies │ │ └── index.ts ├── index.ts └── sandbox-code-generator │ ├── index.ts │ ├── types.ts │ ├── sandbox-provider.ts │ ├── mcp-client.ts │ ├── vercel-sandbox-provider.ts │ ├── prompts.ts │ ├── schema-converter.ts │ ├── file-adapter.ts │ ├── local-sandbox-provider.ts │ ├── e2b-sandbox-provider.ts │ ├── sandbox-utils.ts │ ├── sandbox-manager.ts │ ├── sandbox-tools.ts │ └── file-generator.ts ├── tsconfig.json ├── .gitignore ├── LICENSE.md ├── package.json ├── examples ├── mcp │ ├── vercel_mcp_simple.ts │ ├── vercel_mcp_search.ts │ ├── e2b_mcp_search.ts │ └── local_mcp_search.ts ├── tools │ └── weather_tool_sandbox.ts └── ctx-management │ └── email_management.ts ├── tests └── boundary.spec.ts └── README.md /.env.example: -------------------------------------------------------------------------------- 1 | AI_GATEWAY_API_KEY="" 2 | BLOB_READ_WRITE_TOKEN="" 3 | 4 | # If using vercel sandbox 5 | VERCEL_OIDC_TOKEN="" 6 | 7 | # If using e2b as sandbox 8 | E2B_API_KEY="" -------------------------------------------------------------------------------- /src/tool-results-compactor/index.ts: -------------------------------------------------------------------------------- 1 | // Tool Results Compactor - Public API 2 | 3 | export { compact } from "./compact"; 4 | export type { Boundary, CompactOptions } from "./compact"; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ES2022", 5 | "moduleResolution": "bundler", 6 | "strict": true, 7 | "declaration": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true, 12 | "outDir": "dist", 13 | "types": [ 14 | "node" 15 | ] 16 | }, 17 | "include": [ 18 | "src/**/*" 19 | ] 20 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node dependencies 2 | node_modules/ 3 | 4 | # Logs 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | pnpm-debug.log* 9 | *.log 10 | 11 | # Build output 12 | dist/ 13 | coverage/ 14 | *.tsbuildinfo 15 | 16 | # Generated MCP files from examples 17 | generated-mcp-files/ 18 | 19 | # Context compression storage 20 | .sandbox/ 21 | .sandbox-local/ 22 | .sandbox-e2b/ 23 | .sandbox-vercel/ 24 | .sandbox-weather/ 25 | 26 | # Environment files 27 | .env 28 | .env.local 29 | .env.development 30 | .env.test 31 | .env.production 32 | 33 | # Editor/OS files 34 | .DS_Store 35 | .idea/ 36 | .vscode/ 37 | 38 | # Vercel/Next (if used in examples or downstream) 39 | .vercel/ 40 | .next/ 41 | node_modules 42 | .env 43 | dist 44 | 45 | .vercel 46 | .env*.local 47 | NOTES.md -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Public API barrel for npm package consumers 2 | 3 | // Tool Results Compactor 4 | export { compact } from "./tool-results-compactor/index.js"; 5 | export type { 6 | Boundary, 7 | CompactOptions, 8 | } from "./tool-results-compactor/index.js"; 9 | 10 | // Sandbox Code Generator 11 | export { 12 | createE2BSandboxCodeMode, 13 | createLocalSandboxCodeMode, 14 | createVercelSandboxCodeMode, 15 | E2BSandboxProvider, 16 | LocalSandboxProvider, 17 | SANDBOX_SYSTEM_PROMPT, 18 | SandboxManager, 19 | VercelSandboxProvider, 20 | } from "./sandbox-code-generator/index"; 21 | export type { 22 | E2BSandboxOptions, 23 | FileAdapter, 24 | FileReadParams, 25 | FileWriteParams, 26 | FileWriteResult, 27 | LocalFileAdapterOptions, 28 | LocalSandboxOptions, 29 | MCPServerConfig, 30 | SandboxCodeModeOptions, 31 | SandboxCodeModeResult, 32 | SandboxProvider, 33 | SandboxProviderOptions, 34 | ToolCodeGenerationResult, 35 | VercelSandboxOptions, 36 | } from "./sandbox-code-generator/index"; 37 | -------------------------------------------------------------------------------- /src/tool-results-compactor/lib/resolver.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { 3 | LocalFileAdapter, 4 | fileUriToOptions, 5 | type FileAdapter as IFileAdapter, 6 | } from "../../sandbox-code-generator/file-adapter.js"; 7 | 8 | export type UriOrAdapter = string | IFileAdapter | undefined; 9 | 10 | export function createFileAdapter(uriOrAdapter?: UriOrAdapter): IFileAdapter { 11 | if (typeof uriOrAdapter === "object" && uriOrAdapter) return uriOrAdapter; 12 | const uri = typeof uriOrAdapter === "string" ? uriOrAdapter : undefined; 13 | if (!uri) { 14 | return new LocalFileAdapter({ baseDir: process.cwd() }); 15 | } 16 | const lower = uri.toLowerCase(); 17 | if (lower.startsWith("file:")) { 18 | const options = fileUriToOptions(uri); 19 | return new LocalFileAdapter(options); 20 | } 21 | throw new Error( 22 | `Unsupported storage URI: ${uri}. Only file:// URIs are supported.` 23 | ); 24 | } 25 | 26 | export function resolveFileUriFromBaseDir( 27 | baseDir: string, 28 | sessionId?: string 29 | ): string { 30 | const abs = path.resolve(baseDir); 31 | return `file://${abs}`; 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Langtrace AI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/index.ts: -------------------------------------------------------------------------------- 1 | // Sandbox Code Generator - Public API 2 | 3 | export { 4 | E2BSandboxProvider, 5 | type E2BSandboxOptions, 6 | } from "./e2b-sandbox-provider.js"; 7 | export { 8 | type FileAdapter, 9 | type FileReadParams, 10 | type FileWriteParams, 11 | type FileWriteResult, 12 | type LocalFileAdapterOptions, 13 | } from "./file-adapter.js"; 14 | export { 15 | LocalSandboxProvider, 16 | type LocalSandboxOptions, 17 | } from "./local-sandbox-provider.js"; 18 | export { SANDBOX_SYSTEM_PROMPT } from "./prompts.js"; 19 | export { SandboxManager } from "./sandbox-manager.js"; 20 | export type { 21 | SandboxProvider, 22 | SandboxProviderOptions, 23 | } from "./sandbox-provider.js"; 24 | export { 25 | createE2BSandboxCodeMode, 26 | createLocalSandboxCodeMode, 27 | createVercelSandboxCodeMode, 28 | type SandboxCodeModeOptions, 29 | type SandboxCodeModeResult, 30 | } from "./sandbox-utils.js"; 31 | export type { ToolCodeGenerationResult } from "./tool-code-writer.js"; 32 | export type { 33 | MCPServerConfig, 34 | ServerToolsMap, 35 | ToolDefinition, 36 | } from "./types.js"; 37 | export { 38 | VercelSandboxProvider, 39 | type VercelSandboxOptions, 40 | } from "./vercel-sandbox-provider.js"; 41 | -------------------------------------------------------------------------------- /src/tool-results-compactor/lib/grep.ts: -------------------------------------------------------------------------------- 1 | import readline from "node:readline"; 2 | import type { FileAdapter } from "../../sandbox-code-generator/file-adapter.js"; 3 | 4 | export interface GrepResultLine { 5 | lineNumber: number; 6 | line: string; 7 | } 8 | 9 | export async function grepObject( 10 | adapter: FileAdapter, 11 | key: string, 12 | pattern: RegExp 13 | ): Promise { 14 | if (adapter.openReadStream) { 15 | const stream = await adapter.openReadStream({ key }); 16 | const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); 17 | const out: GrepResultLine[] = []; 18 | let lineNumber = 0; 19 | for await (const line of rl) { 20 | lineNumber++; 21 | if (pattern.test(line)) out.push({ lineNumber, line }); 22 | } 23 | return out; 24 | } 25 | 26 | if (adapter.readText) { 27 | const text = await adapter.readText({ key }); 28 | const out: GrepResultLine[] = []; 29 | const lines = text.split(/\r?\n/); 30 | lines.forEach((line, idx) => { 31 | if (pattern.test(line)) out.push({ lineNumber: idx + 1, line }); 32 | }); 33 | return out; 34 | } 35 | 36 | throw new Error("Adapter does not support read operations needed for grep."); 37 | } 38 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/types.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for MCP Sandbox Explorer 2 | 3 | import type { Tool } from "ai"; 4 | import type { 5 | SandboxProvider, 6 | SandboxProviderOptions, 7 | } from "./sandbox-provider.js"; 8 | import type { ToolCodeGenerationOptions } from "./tool-code-writer.js"; 9 | 10 | export interface MCPServerConfig { 11 | name: string; 12 | url: string; 13 | headers?: Record; 14 | useSSE?: boolean; 15 | } 16 | 17 | export interface SandboxManagerConfig { 18 | /** 19 | * Custom sandbox provider. If not provided, defaults to LocalSandboxProvider 20 | */ 21 | sandboxProvider?: SandboxProvider; 22 | /** 23 | * Options for the sandbox provider (used if sandboxProvider is not provided) 24 | */ 25 | sandboxOptions?: SandboxProviderOptions; 26 | } 27 | 28 | /** 29 | * @deprecated Use SandboxManagerConfig instead 30 | */ 31 | export interface SandboxExplorerConfig extends SandboxManagerConfig { 32 | /** 33 | * @deprecated Use register() method instead 34 | */ 35 | servers?: MCPServerConfig[]; 36 | /** 37 | * @deprecated Use register() method instead 38 | */ 39 | standardTools?: Record>; 40 | /** 41 | * @deprecated Use register() method instead 42 | */ 43 | standardToolOptions?: ToolCodeGenerationOptions; 44 | /** 45 | * @deprecated Use register() method instead 46 | */ 47 | outputDir?: string; 48 | } 49 | 50 | export interface ToolDefinition { 51 | name: string; 52 | description?: string; 53 | inputSchema: any; 54 | } 55 | 56 | export interface ServerToolsMap { 57 | [serverName: string]: ToolDefinition[]; 58 | } 59 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/sandbox-provider.ts: -------------------------------------------------------------------------------- 1 | // Abstract sandbox provider interface 2 | 3 | /** 4 | * Command execution result 5 | */ 6 | export interface CommandResult { 7 | exitCode: number; 8 | stdout(): Promise; 9 | stderr(): Promise; 10 | } 11 | 12 | /** 13 | * File to write to sandbox 14 | */ 15 | export interface SandboxFile { 16 | path: string; 17 | content: Buffer; 18 | } 19 | 20 | /** 21 | * Command to execute in sandbox 22 | */ 23 | export interface SandboxCommand { 24 | cmd: string; 25 | args: string[]; 26 | } 27 | 28 | /** 29 | * Abstract sandbox provider interface 30 | * Implement this to add support for different sandbox environments 31 | */ 32 | export interface SandboxProvider { 33 | /** 34 | * Write multiple files to the sandbox 35 | */ 36 | writeFiles(files: SandboxFile[]): Promise; 37 | 38 | /** 39 | * Execute a command in the sandbox 40 | */ 41 | runCommand(command: SandboxCommand): Promise; 42 | 43 | /** 44 | * Stop/cleanup the sandbox 45 | */ 46 | stop(): Promise; 47 | 48 | /** 49 | * Get a unique identifier for this sandbox instance 50 | */ 51 | getId(): string; 52 | 53 | /** 54 | * Get the workspace directory path (where MCP tools and user code live) 55 | * This should be the root directory for all generated files. 56 | * @example "/workspace", "/home/sandbox", "/vercel/sandbox", etc. 57 | */ 58 | getWorkspacePath(): string; 59 | } 60 | 61 | /** 62 | * Options for creating a sandbox provider 63 | */ 64 | export interface SandboxProviderOptions { 65 | timeout?: number; 66 | runtime?: string; 67 | vcpus?: number; 68 | [key: string]: any; // Allow provider-specific options 69 | } 70 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/mcp-client.ts: -------------------------------------------------------------------------------- 1 | // Standalone MCP client for connecting to MCP servers and fetching tool definitions 2 | 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 4 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; 5 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; 6 | import type { MCPServerConfig, ToolDefinition } from "./types.js"; 7 | 8 | /** 9 | * Connect to an MCP server and fetch all available tool definitions 10 | */ 11 | export async function fetchToolDefinitions( 12 | serverConfig: MCPServerConfig 13 | ): Promise { 14 | const { url, useSSE = false, headers = {} } = serverConfig; 15 | 16 | const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined; 17 | 18 | const transport = useSSE 19 | ? new SSEClientTransport( 20 | new URL(url), 21 | requestInit ? { requestInit } : undefined 22 | ) 23 | : new StreamableHTTPClientTransport(new URL(url), { requestInit }); 24 | 25 | const client = new Client( 26 | { 27 | name: "ctx-zip", 28 | version: "0.0.7", 29 | }, 30 | { 31 | capabilities: {}, 32 | } 33 | ); 34 | 35 | try { 36 | await Promise.race([ 37 | client.connect(transport), 38 | new Promise((_, reject) => 39 | setTimeout( 40 | () => reject(new Error("Connection timeout after 30 seconds")), 41 | 30000 42 | ) 43 | ), 44 | ]); 45 | 46 | const toolsResult = await client.listTools(); 47 | 48 | const tools: ToolDefinition[] = toolsResult.tools.map((tool: any) => ({ 49 | name: tool.name, 50 | description: tool.description, 51 | inputSchema: tool.inputSchema, 52 | })); 53 | 54 | return tools; 55 | } catch (error) { 56 | throw new Error( 57 | `Failed to fetch tools from ${serverConfig.name}: ${ 58 | error instanceof Error ? error.message : String(error) 59 | }` 60 | ); 61 | } finally { 62 | await client.close(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/vercel-sandbox-provider.ts: -------------------------------------------------------------------------------- 1 | // Vercel Sandbox provider implementation 2 | 3 | import { Sandbox } from "@vercel/sandbox"; 4 | import { randomUUID } from "node:crypto"; 5 | import type { 6 | CommandResult, 7 | SandboxCommand, 8 | SandboxFile, 9 | SandboxProvider, 10 | SandboxProviderOptions, 11 | } from "./sandbox-provider.js"; 12 | 13 | /** 14 | * Vercel-specific sandbox options 15 | */ 16 | export interface VercelSandboxOptions extends SandboxProviderOptions { 17 | runtime?: "node22" | "python3.13"; 18 | vcpus?: number; 19 | } 20 | 21 | /** 22 | * Vercel Sandbox provider implementation 23 | */ 24 | export class VercelSandboxProvider implements SandboxProvider { 25 | private sandbox: Sandbox; 26 | private workspacePath: string = "/vercel/sandbox"; 27 | private id: string; 28 | 29 | private constructor(sandbox: Sandbox) { 30 | this.sandbox = sandbox; 31 | const providedId = 32 | sandbox && typeof (sandbox as any).id === "string" 33 | ? (sandbox as any).id 34 | : undefined; 35 | this.id = providedId 36 | ? `vercel-sandbox-${providedId}` 37 | : `vercel-sandbox-${randomUUID().replace(/-/g, "").slice(0, 12)}`; 38 | } 39 | 40 | /** 41 | * Create a new Vercel sandbox instance 42 | */ 43 | static async create( 44 | options: VercelSandboxOptions = {} 45 | ): Promise { 46 | console.log( 47 | `✓ Creating Vercel Sandbox (runtime: ${options.runtime || "node22"})` 48 | ); 49 | 50 | const sandbox = await Sandbox.create({ 51 | timeout: options.timeout || 1800000, 52 | runtime: options.runtime || "node22", 53 | resources: { 54 | vcpus: options.vcpus || 4, 55 | }, 56 | }); 57 | 58 | console.log("✓ Sandbox created"); 59 | return new VercelSandboxProvider(sandbox); 60 | } 61 | 62 | async writeFiles(files: SandboxFile[]): Promise { 63 | await this.sandbox.writeFiles(files); 64 | } 65 | 66 | async runCommand(command: SandboxCommand): Promise { 67 | return await this.sandbox.runCommand({ 68 | cmd: command.cmd, 69 | args: command.args, 70 | }); 71 | } 72 | 73 | async stop(): Promise { 74 | await this.sandbox.stop(); 75 | } 76 | 77 | getId(): string { 78 | return this.id; 79 | } 80 | 81 | getWorkspacePath(): string { 82 | return this.workspacePath; 83 | } 84 | 85 | /** 86 | * Get the underlying Vercel sandbox instance (for advanced use cases) 87 | */ 88 | getVercelSandbox(): Sandbox { 89 | return this.sandbox; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ctx-zip", 3 | "version": "1.0.6", 4 | "description": "Keep your AI agent context small and cheap by managing tool bloat and large outputs.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "example:mcp-vercel": "tsx examples/mcp/vercel_mcp_search.ts", 9 | "example:mcp-vercel-simple": "tsx examples/mcp/vercel_mcp_simple.ts", 10 | "example:mcp-e2b": "tsx examples/mcp/e2b_mcp_search.ts", 11 | "example:mcp-local": "tsx examples/mcp/local_mcp_search.ts", 12 | "example:tools-weather": "tsx examples/tools/weather_tool_sandbox.ts", 13 | "example:tools-weather-local": "npm run example:tools-weather -- --provider=local", 14 | "example:tools-weather-vercel": "npm run example:tools-weather -- --provider=vercel", 15 | "example:tools-weather-e2b": "npm run example:tools-weather -- --provider=e2b", 16 | "example:ctx-email": "tsx examples/ctx-management/email_management.ts", 17 | "example:ctx-email-write": "tsx examples/ctx-management/email_management.ts --strategy=write-tool-results-to-file", 18 | "example:ctx-email-drop": "tsx examples/ctx-management/email_management.ts --strategy=drop-tool-results", 19 | "build": "tsc -p tsconfig.json", 20 | "start": "node dist/index.js", 21 | "test": "tsx tests/*.ts", 22 | "prepublishOnly": "npm run build" 23 | }, 24 | "keywords": [ 25 | "ai", 26 | "tools", 27 | "context", 28 | "compression", 29 | "mcp", 30 | "model-context-protocol", 31 | "sandbox", 32 | "code-execution", 33 | "progressive-discovery" 34 | ], 35 | "author": "Karthik Kalyanaraman", 36 | "license": "MIT", 37 | "type": "module", 38 | "exports": { 39 | ".": { 40 | "types": "./dist/index.d.ts", 41 | "import": "./dist/index.js", 42 | "require": "./dist/index.js" 43 | } 44 | }, 45 | "files": [ 46 | "dist" 47 | ], 48 | "dependencies": { 49 | "@connectrpc/connect": "^2.0.0-rc.3", 50 | "zod": "^4.1.11" 51 | }, 52 | "peerDependencies": { 53 | "ai": "^5.0.0" 54 | }, 55 | "peerDependenciesMeta": { 56 | "@modelcontextprotocol/sdk": { 57 | "optional": true 58 | }, 59 | "@vercel/sandbox": { 60 | "optional": true 61 | }, 62 | "@e2b/code-interpreter": { 63 | "optional": true 64 | } 65 | }, 66 | "optionalDependencies": { 67 | "@e2b/code-interpreter": "^1.5.1", 68 | "@modelcontextprotocol/sdk": "^1.0.4", 69 | "@vercel/sandbox": "^1.0.2" 70 | }, 71 | "devDependencies": { 72 | "@ai-sdk/openai": "^2.0.35", 73 | "@connectrpc/connect": "^2.0.0-rc.3", 74 | "@tokenlens/helpers": "^1.3.1", 75 | "@types/node": "^24.5.2", 76 | "@types/prompts": "^2.4.9", 77 | "ai": "^5.0.52", 78 | "chalk": "^5.6.2", 79 | "dotenv": "^17.2.2", 80 | "prompts": "^2.4.2", 81 | "tokenlens": "^1.3.1", 82 | "tsx": "^4.20.5", 83 | "typescript": "^5.9.2" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/prompts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * System prompts for sandbox-based AI assistants 3 | */ 4 | 5 | /** 6 | * Default system prompt for sandbox code execution assistants. 7 | * This prompt guides the AI to explore the sandbox structure before writing code, 8 | * ensuring it understands available MCP and local tools. 9 | */ 10 | export const SANDBOX_SYSTEM_PROMPT = `You are a helpful assistant with access to a local sandbox, MCP tools, and standard tools. 11 | 12 | 🚨 CRITICAL FIRST STEP - ALWAYS READ THIS BEFORE ANY TASK: 13 | Before writing ANY code, you MUST explore the sandbox to understand what tools are available. 14 | 15 | Available directories (use relative paths from sandbox root): 16 | - mcp/: MCP (Model Context Protocol) tool definitions 17 | - Contains subdirectories for each MCP server 18 | - README at: mcp/README.md 19 | - local-tools/: Standard tool definitions 20 | - README at: local-tools/README.md 21 | - user-code/: Your workspace for writing scripts 22 | - README at: user-code/README.md (START HERE!) 23 | 24 | Available sandbox tools: 25 | - sandbox_ls: List directory contents 26 | - sandbox_cat: Read files (e.g., README.md, tool definitions) 27 | - sandbox_find: Find files by name pattern 28 | - sandbox_grep: Search for patterns in files 29 | - sandbox_exec: Execute TypeScript code in the sandbox 30 | - sandbox_write_file: Write file to the sandbox 31 | - sandbox_lint: Lint a TypeScript file in the sandbox 32 | - sandbox_edit_file: Edit file in the sandbox 33 | - sandbox_delete_file: Delete file from the sandbox 34 | 35 | 📋 MANDATORY WORKFLOW: 36 | 1. 🔍 EXPLORE FIRST (NEVER SKIP THIS): 37 | - Read user-code/README.md for essential import instructions 38 | - List available directories: sandbox_ls({ path: 'mcp' }) and sandbox_ls({ path: 'local-tools' }) 39 | - Read README files: sandbox_cat({ file: 'mcp/README.md' }) and sandbox_cat({ file: 'local-tools/README.md' }) 40 | - List tools in each directory to see what's available 41 | - Read specific tool files to understand exact APIs (function names, parameters, return types) 42 | 43 | Example discovery pattern: 44 | - Start: sandbox_cat({ file: 'user-code/README.md' }) 45 | - Discover: sandbox_ls({ path: 'mcp' }) or sandbox_ls({ path: 'local-tools' }) 46 | - Explore: sandbox_ls({ path: 'mcp/server-name' }) to drill into a specific server 47 | - Learn: sandbox_cat({ file: 'path/to/tool.ts' }) to read tool implementation 48 | 49 | 2. ✍️ WRITE CODE: 50 | - Now that you know the APIs, write correct code on first try 51 | - Use the exact imports and function signatures you discovered 52 | 53 | 3. 🔍 LINT (OPTIONAL): 54 | - Use sandbox_lint to check for errors if unsure 55 | - If errors exist, fix them based on the tool definitions you read 56 | 57 | 4. ▶️ EXECUTE: 58 | - Run the code with sandbox_exec 59 | 60 | 5. 📊 SHOW RESULTS: 61 | - Always show actual results, not just confirmation 62 | 63 | ⚠️ DO NOT: 64 | - Skip the exploration step 65 | - Guess at API signatures 66 | - Write code before reading tool definitions 67 | - Create unnecessary files 68 | - Run lint multiple times - get it right by reading the definitions first 69 | 70 | 💡 TIPS: 71 | - Always start with user-code/README.md 72 | - Use sandbox_ls to discover what's available 73 | - Use sandbox_cat to understand how tools work 74 | - The sandbox persists between sessions - files you create remain available 75 | 76 | Be conversational and helpful. Explore the sandbox to discover capabilities, then use them effectively.`; 77 | -------------------------------------------------------------------------------- /src/tool-results-compactor/compact.ts: -------------------------------------------------------------------------------- 1 | import type { ModelMessage } from "ai"; 2 | import type { FileAdapter } from "../sandbox-code-generator/file-adapter.js"; 3 | import { createFileAdapter, type UriOrAdapter } from "./lib/resolver.js"; 4 | import { 5 | dropToolResultsStrategy, 6 | writeToolResultsToFileStrategy, 7 | type Boundary, 8 | } from "./strategies/index.js"; 9 | 10 | /** 11 | * Options for compacting a conversation by persisting large tool outputs to storage 12 | * and replacing them with lightweight references. 13 | */ 14 | export interface CompactOptions { 15 | /** 16 | * Compaction strategy to use. Currently only "write-tool-results-to-file" is supported. 17 | */ 18 | strategy?: "write-tool-results-to-file" | string; 19 | /** 20 | * Storage location to persist tool outputs. Accepts FileAdapter instance or URI string. 21 | * If omitted, defaults to the current working directory. 22 | */ 23 | storage?: UriOrAdapter; 24 | /** 25 | * Controls where the compaction window starts. Defaults to "all". 26 | * - "all": Compact entire conversation 27 | * - { type: "keep-first", count: N }: Keep first N messages intact 28 | * - { type: "keep-last", count: N }: Keep last N messages intact 29 | */ 30 | boundary?: Boundary; 31 | /** 32 | * Function to convert tool outputs (objects) to strings before writing to storage. 33 | * Defaults to JSON.stringify(value, null, 2). 34 | */ 35 | toolResultSerializer?: (value: unknown) => string; 36 | /** 37 | * Tool names that are recognized as reading from storage (e.g., read/search tools). Their results 38 | * will not be re-written; instead, a friendly reference to the source is shown. Provide custom names 39 | * if you use your own read/search tools. 40 | */ 41 | fileReaderTools?: string[]; 42 | /** 43 | * Optional session ID to organize persisted tool results. 44 | * Files will be organized as: {storage}/{sessionId}/tool-results/{toolName}-{seq}.json 45 | * If omitted, a random session ID will be generated. 46 | */ 47 | sessionId?: string; 48 | } 49 | 50 | /** 51 | * Compact a sequence of messages by writing large tool outputs to a configured storage and 52 | * replacing them with succinct references, keeping your model context lean. 53 | */ 54 | export async function compact( 55 | messages: ModelMessage[], 56 | options: CompactOptions = {} 57 | ): Promise { 58 | const strategy = options.strategy ?? "write-tool-results-to-file"; 59 | // Default: compact the entire conversation 60 | const boundary: Boundary = options.boundary ?? "all"; 61 | const adapter: FileAdapter = 62 | typeof options.storage === "object" && options.storage 63 | ? options.storage 64 | : createFileAdapter(options.storage); 65 | const toolResultSerializer = 66 | options.toolResultSerializer ?? ((v) => JSON.stringify(v, null, 2)); 67 | 68 | switch (strategy) { 69 | case "write-tool-results-to-file": 70 | return await writeToolResultsToFileStrategy(messages, { 71 | boundary, 72 | adapter, 73 | toolResultSerializer, 74 | fileReaderTools: [ 75 | "sandbox_ls", 76 | "sandbox_cat", 77 | "sandbox_grep", 78 | "sandbox_find", 79 | ...(options.fileReaderTools ?? []), 80 | ], 81 | sessionId: options.sessionId, 82 | }); 83 | case "drop-tool-results": 84 | return await dropToolResultsStrategy(messages, { 85 | boundary, 86 | }); 87 | default: 88 | throw new Error(`Unknown compaction strategy: ${strategy}`); 89 | } 90 | } 91 | 92 | export type { Boundary } from "./strategies/index.js"; 93 | -------------------------------------------------------------------------------- /examples/mcp/vercel_mcp_simple.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple Vercel MCP Example 3 | * 4 | * This example demonstrates: 5 | * - VercelSandboxProvider: Cloud sandbox execution 6 | * - MCP (Model Context Protocol) with Vercel MCP server 7 | * 8 | * Required Environment Variables: 9 | * - OPENAI_API_KEY (required) 10 | * 11 | * Usage: 12 | * npm run example:mcp-vercel-simple 13 | * 14 | * This example shows sandbox invocation and tool registration. 15 | */ 16 | 17 | import { stepCountIs, streamText, tool } from "ai"; 18 | import dotenv from "dotenv"; 19 | import { z } from "zod"; 20 | import { SANDBOX_SYSTEM_PROMPT } from "../../src/sandbox-code-generator/prompts.js"; 21 | import { createVercelSandboxCodeMode } from "../../src/sandbox-code-generator/sandbox-utils.js"; 22 | 23 | // Load environment variables 24 | dotenv.config(); 25 | 26 | // Simple weather tool 27 | const weatherTool = tool({ 28 | description: "Get the current weather for a location", 29 | inputSchema: z.object({ 30 | location: z.string().describe("The city or location to get weather for"), 31 | units: z 32 | .enum(["celsius", "fahrenheit"]) 33 | .optional() 34 | .default("fahrenheit") 35 | .describe("Temperature units"), 36 | }), 37 | async execute({ location, units }) { 38 | // Simulate weather API call with mock data 39 | const baseTemp = units === "celsius" ? 22 : 72; 40 | const variation = Math.floor(Math.random() * 21) - 10; 41 | const temperature = baseTemp + variation; 42 | 43 | const conditions = ["sunny", "cloudy", "partly cloudy", "rainy", "clear"]; 44 | const condition = conditions[Math.floor(Math.random() * conditions.length)]; 45 | 46 | return { 47 | location, 48 | temperature, 49 | units: units === "celsius" ? "°C" : "°F", 50 | condition, 51 | humidity: Math.floor(Math.random() * 40) + 40, 52 | windSpeed: Math.floor(Math.random() * 15) + 5, 53 | timestamp: new Date().toISOString(), 54 | }; 55 | }, 56 | }); 57 | 58 | async function main() { 59 | console.log("\n🚀 Simple Vercel MCP Example\n"); 60 | 61 | // Create Vercel sandbox in code mode (transforms MCP servers and tools into executable code) 62 | // sandboxOptions are optional - defaults are applied automatically (30min timeout, node22, 4 vcpus) 63 | const { tools, manager } = await createVercelSandboxCodeMode({ 64 | servers: [ 65 | { 66 | name: "vercel", 67 | url: "https://mcp.vercel.com", 68 | useSSE: false, 69 | headers: { 70 | Authorization: `Bearer ${process.env.VERCEL_API_KEY}`, 71 | }, 72 | }, 73 | ], 74 | standardTools: { 75 | weather: weatherTool, 76 | }, 77 | }); 78 | 79 | const query = "What tools are available from the Vercel MCP server?"; 80 | 81 | console.log(`You: ${query}\n`); 82 | 83 | try { 84 | const result = streamText({ 85 | model: "openai/gpt-4.1-mini", 86 | tools, 87 | stopWhen: stepCountIs(20), 88 | system: SANDBOX_SYSTEM_PROMPT, 89 | messages: [ 90 | { 91 | role: "user", 92 | content: query, 93 | }, 94 | ], 95 | }); 96 | 97 | // Stream the assistant response 98 | process.stdout.write("Assistant: "); 99 | for await (const textPart of result.textStream) { 100 | process.stdout.write(textPart); 101 | } 102 | console.log("\n"); 103 | 104 | // Wait for final response 105 | await result.response; 106 | 107 | console.log("\n✅ Query complete!\n"); 108 | } catch (error: any) { 109 | console.error(`\n❌ Error: ${error.message}\n`); 110 | if (error.stack) { 111 | console.error(`Stack trace: ${error.stack}\n`); 112 | } 113 | } 114 | 115 | // Cleanup 116 | console.log("🧹 Cleaning up..."); 117 | await manager.cleanup(); 118 | console.log("✅ Done!\n"); 119 | } 120 | 121 | main().catch(async (error) => { 122 | console.error("❌ Error:", error); 123 | process.exit(1); 124 | }); 125 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/schema-converter.ts: -------------------------------------------------------------------------------- 1 | // Convert JSON Schema to TypeScript interfaces and JSDoc 2 | 3 | /** 4 | * Generate TypeScript interface from JSON Schema 5 | */ 6 | export function generateTypeScriptInterface( 7 | schema: any, 8 | interfaceName: string 9 | ): string { 10 | if (!schema || !schema.properties) { 11 | return `export interface ${interfaceName} {\n [key: string]: any;\n}`; 12 | } 13 | 14 | const properties = schema.properties; 15 | const required = schema.required || []; 16 | 17 | const lines: string[] = [`export interface ${interfaceName} {`]; 18 | 19 | for (const [propName, propSchema] of Object.entries(properties)) { 20 | const isRequired = required.includes(propName); 21 | const optional = isRequired ? "" : "?"; 22 | const propType = jsonSchemaTypeToTS(propSchema as any); 23 | 24 | // Add JSDoc comment if description exists 25 | const desc = (propSchema as any).description; 26 | if (desc) { 27 | lines.push(` /** ${desc} */`); 28 | } 29 | 30 | lines.push(` ${propName}${optional}: ${propType};`); 31 | } 32 | 33 | lines.push("}"); 34 | return lines.join("\n"); 35 | } 36 | 37 | /** 38 | * Convert JSON Schema type to TypeScript type 39 | */ 40 | function jsonSchemaTypeToTS(schema: any): string { 41 | if (schema.enum) { 42 | return schema.enum.map((v: any) => JSON.stringify(v)).join(" | "); 43 | } 44 | 45 | if (schema.type === "array") { 46 | if (schema.items) { 47 | const itemType = jsonSchemaTypeToTS(schema.items); 48 | return `${itemType}[]`; 49 | } 50 | return "any[]"; 51 | } 52 | 53 | if (schema.type === "object") { 54 | if (schema.properties) { 55 | // Inline object type 56 | const props = Object.entries(schema.properties) 57 | .map(([key, val]) => { 58 | const required = schema.required || []; 59 | const optional = required.includes(key) ? "" : "?"; 60 | return `${key}${optional}: ${jsonSchemaTypeToTS(val)}`; 61 | }) 62 | .join("; "); 63 | return `{ ${props} }`; 64 | } 65 | return "Record"; 66 | } 67 | 68 | switch (schema.type) { 69 | case "string": 70 | return "string"; 71 | case "number": 72 | case "integer": 73 | return "number"; 74 | case "boolean": 75 | return "boolean"; 76 | case "null": 77 | return "null"; 78 | default: 79 | return "any"; 80 | } 81 | } 82 | 83 | /** 84 | * Extract JSDoc comment from schema description 85 | */ 86 | export function extractJSDocFromSchema( 87 | schema: any, 88 | toolName: string, 89 | toolDescription?: string, 90 | serverName?: string 91 | ): string { 92 | const lines: string[] = ["/**"]; 93 | 94 | // Use tool description if provided, otherwise schema description 95 | const description = toolDescription || schema.description || toolName; 96 | lines.push(` * ${description}`); 97 | 98 | if (schema.properties) { 99 | lines.push(" *"); 100 | lines.push(" * @param input - The input parameters"); 101 | for (const [propName, propSchema] of Object.entries(schema.properties)) { 102 | const desc = (propSchema as any).description || propName; 103 | const required = schema.required?.includes(propName) 104 | ? "(required)" 105 | : "(optional)"; 106 | lines.push(` * @param input.${propName} - ${desc} ${required}`); 107 | } 108 | } 109 | 110 | lines.push(" *"); 111 | lines.push(` * @returns Promise with the result of ${toolName}`); 112 | 113 | // Add response format documentation 114 | lines.push(" *"); 115 | lines.push( 116 | " * @note Response format: The MCP tool returns an object, not an array." 117 | ); 118 | lines.push( 119 | " * For text responses, access the data via the returned object/string." 120 | ); 121 | lines.push( 122 | " * Example: const result = await tool(...); // result is an object or string" 123 | ); 124 | lines.push( 125 | " * Always log the response first to understand its structure!" 126 | ); 127 | 128 | lines.push(" */"); 129 | 130 | return lines.join("\n"); 131 | } 132 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/file-adapter.ts: -------------------------------------------------------------------------------- 1 | import type { ReadStream } from "node:fs"; 2 | import fs from "node:fs"; 3 | import { 4 | readFile as fsReadFile, 5 | writeFile as fsWriteFile, 6 | } from "node:fs/promises"; 7 | import path from "node:path"; 8 | import { fileURLToPath } from "node:url"; 9 | import type { SandboxProvider } from "./sandbox-provider.js"; 10 | 11 | export interface FileWriteParams { 12 | key: string; 13 | body: string | Uint8Array; 14 | contentType?: string; 15 | } 16 | 17 | export interface FileReadParams { 18 | key: string; 19 | } 20 | 21 | export interface FileWriteResult { 22 | key: string; 23 | url?: string; 24 | } 25 | 26 | export interface FileAdapter { 27 | write(params: FileWriteParams): Promise; 28 | readText?(params: FileReadParams): Promise; 29 | openReadStream?( 30 | params: FileReadParams 31 | ): Promise; 32 | resolveKey(name: string): string; 33 | toString(): string; 34 | } 35 | 36 | /** 37 | * Options for creating a local file adapter 38 | */ 39 | export interface LocalFileAdapterOptions { 40 | baseDir: string; // absolute directory 41 | prefix?: string; // optional subdir/prefix inside baseDir (defaults to "compact") 42 | sessionId?: string; // optional session ID for organizing tool results 43 | } 44 | 45 | /** 46 | * File adapter that writes to the local filesystem 47 | */ 48 | export class LocalFileAdapter implements FileAdapter { 49 | private baseDir: string; 50 | private prefix: string; 51 | private sessionId: string | undefined; 52 | 53 | constructor(options: LocalFileAdapterOptions) { 54 | this.baseDir = options.baseDir; 55 | this.prefix = options.prefix ?? "compact"; 56 | this.sessionId = options.sessionId; 57 | } 58 | 59 | resolveKey(name: string): string { 60 | const safe = name.replace(/\\/g, "/").replace(/\.+\//g, ""); 61 | 62 | // Build path with session support: [prefix/][sessionId/tool-results/]name 63 | const parts: string[] = []; 64 | if (this.prefix) parts.push(this.prefix.replace(/\/$/, "")); 65 | if (this.sessionId) parts.push(this.sessionId, "tool-results"); 66 | parts.push(safe); 67 | 68 | return parts.join("/"); 69 | } 70 | 71 | async write(params: FileWriteParams): Promise { 72 | const fullPath = path.resolve(this.baseDir, params.key); 73 | await fs.promises.mkdir(path.dirname(fullPath), { recursive: true }); 74 | const body = 75 | typeof params.body === "string" ? params.body : Buffer.from(params.body); 76 | await fsWriteFile(fullPath, body, "utf8"); 77 | const url = new URL(`file://${fullPath}`); 78 | return { key: params.key, url: url.toString() }; 79 | } 80 | 81 | async readText(params: FileReadParams): Promise { 82 | const fullPath = path.resolve(this.baseDir, params.key); 83 | return await fsReadFile(fullPath, "utf8"); 84 | } 85 | 86 | async openReadStream(params: FileReadParams) { 87 | const fullPath = path.resolve(this.baseDir, params.key); 88 | return fs.createReadStream(fullPath); 89 | } 90 | 91 | toString(): string { 92 | // Return just the baseDir - prefix and sessionId are already part of resolved keys 93 | return `file://${this.baseDir}`; 94 | } 95 | } 96 | 97 | export function fileUriToOptions(uri: string): LocalFileAdapterOptions { 98 | // Expect file:///abs/path or file:/abs/path 99 | const url = new URL(uri); 100 | if (url.protocol !== "file:") { 101 | throw new Error(`Invalid file URI: ${uri}`); 102 | } 103 | const baseDir = fileURLToPath(url); 104 | return { baseDir }; 105 | } 106 | 107 | /** 108 | * Options for creating a sandbox file adapter 109 | */ 110 | export interface SandboxFileAdapterOptions { 111 | sandboxProvider: SandboxProvider; 112 | prefix?: string; // optional subdir/prefix inside sandbox workspace 113 | sessionId?: string; // optional session ID for organizing tool results 114 | } 115 | 116 | /** 117 | * File adapter that writes to a sandbox provider (E2B, Vercel, etc.) 118 | * instead of the local filesystem 119 | */ 120 | export class SandboxFileAdapter implements FileAdapter { 121 | private sandboxProvider: SandboxProvider; 122 | private prefix: string; 123 | private sessionId: string | undefined; 124 | private workspacePath: string; 125 | 126 | constructor(options: SandboxFileAdapterOptions) { 127 | this.sandboxProvider = options.sandboxProvider; 128 | this.prefix = options.prefix ?? ""; 129 | this.sessionId = options.sessionId; 130 | this.workspacePath = options.sandboxProvider.getWorkspacePath(); 131 | } 132 | 133 | resolveKey(name: string): string { 134 | const safe = name.replace(/\\/g, "/").replace(/\.+\//g, ""); 135 | 136 | // Build path with session support: [prefix/][sessionId/tool-results/]name 137 | const parts: string[] = []; 138 | if (this.prefix) parts.push(this.prefix.replace(/\/$/, "")); 139 | if (this.sessionId) parts.push(this.sessionId, "tool-results"); 140 | parts.push(safe); 141 | 142 | return parts.join("/"); 143 | } 144 | 145 | async write(params: FileWriteParams): Promise { 146 | const relativePath = params.key; 147 | const fullPath = `${this.workspacePath}/${relativePath}`; 148 | 149 | // Convert body to Buffer if string 150 | const content: Buffer = 151 | typeof params.body === "string" 152 | ? Buffer.from(params.body, "utf-8") 153 | : Buffer.isBuffer(params.body) 154 | ? params.body 155 | : Buffer.from(params.body); 156 | 157 | // Write file to sandbox 158 | await this.sandboxProvider.writeFiles([ 159 | { 160 | path: fullPath, 161 | content, 162 | }, 163 | ]); 164 | 165 | // Return result with sandbox path 166 | const url = `sandbox://${this.sandboxProvider.getId()}/${relativePath}`; 167 | return { key: params.key, url }; 168 | } 169 | 170 | async readText(params: FileReadParams): Promise { 171 | const relativePath = params.key; 172 | const fullPath = `${this.workspacePath}/${relativePath}`; 173 | 174 | // Read file from sandbox using cat command 175 | const result = await this.sandboxProvider.runCommand({ 176 | cmd: "cat", 177 | args: [fullPath], 178 | }); 179 | 180 | if (result.exitCode !== 0) { 181 | const stderr = await result.stderr(); 182 | throw new Error(`Failed to read file ${fullPath}: ${stderr}`); 183 | } 184 | 185 | return await result.stdout(); 186 | } 187 | 188 | async openReadStream(params: FileReadParams) { 189 | // For sandbox, we'll read the entire file and create a stream from it 190 | const content = await this.readText(params); 191 | const { Readable } = await import("stream"); 192 | return Readable.from([content]); 193 | } 194 | 195 | toString(): string { 196 | // Return just the sandbox workspace path - prefix and sessionId are already part of resolved keys 197 | return `sandbox://${this.sandboxProvider.getId()}`; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/local-sandbox-provider.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | import type { 5 | CommandResult, 6 | SandboxCommand, 7 | SandboxFile, 8 | SandboxProvider, 9 | SandboxProviderOptions, 10 | } from "./sandbox-provider.js"; 11 | 12 | /** 13 | * Options for creating a local sandbox 14 | */ 15 | export interface LocalSandboxOptions extends SandboxProviderOptions { 16 | /** 17 | * Directory to use for the sandbox 18 | * @default "./.sandbox" 19 | */ 20 | sandboxDir?: string; 21 | 22 | /** 23 | * Whether to clean the sandbox directory on creation 24 | * @default true 25 | */ 26 | cleanOnCreate?: boolean; 27 | } 28 | 29 | /** 30 | * Command result implementation for local execution 31 | */ 32 | class LocalCommandResult implements CommandResult { 33 | constructor( 34 | public exitCode: number, 35 | private stdoutData: string, 36 | private stderrData: string 37 | ) {} 38 | 39 | async stdout(): Promise { 40 | return this.stdoutData; 41 | } 42 | 43 | async stderr(): Promise { 44 | return this.stderrData; 45 | } 46 | } 47 | 48 | /** 49 | * Local filesystem sandbox provider for development and debugging 50 | * 51 | * This provider writes files to a local directory and executes commands 52 | * using Node.js child_process. Useful for: 53 | * - Debugging MCP client code without E2B 54 | * - Local development and testing 55 | * - Inspecting generated files directly 56 | */ 57 | export class LocalSandboxProvider implements SandboxProvider { 58 | private workspacePath: string; 59 | private cwd: string; 60 | private sandboxId: string; 61 | 62 | private constructor(workspacePath: string) { 63 | this.workspacePath = workspacePath; 64 | this.cwd = workspacePath; 65 | this.sandboxId = `local-${Date.now()}`; 66 | } 67 | 68 | /** 69 | * Create a new local sandbox 70 | */ 71 | static async create( 72 | options: LocalSandboxOptions = {} 73 | ): Promise { 74 | const sandboxDir = options.sandboxDir || "./.sandbox"; 75 | const cleanOnCreate = options.cleanOnCreate ?? true; 76 | 77 | // Resolve to absolute path 78 | const workspacePath = path.resolve(sandboxDir); 79 | 80 | console.log(`✓ Creating local sandbox at: ${workspacePath}`); 81 | 82 | // Clean if requested 83 | if (cleanOnCreate && fs.existsSync(workspacePath)) { 84 | console.log(` Cleaning existing sandbox directory...`); 85 | fs.rmSync(workspacePath, { recursive: true, force: true }); 86 | } 87 | 88 | // Create directory 89 | if (!fs.existsSync(workspacePath)) { 90 | fs.mkdirSync(workspacePath, { recursive: true }); 91 | console.log(` Created directory: ${workspacePath}`); 92 | } 93 | 94 | console.log(`✓ Local sandbox ready`); 95 | return new LocalSandboxProvider(workspacePath); 96 | } 97 | 98 | /** 99 | * Write multiple files to the local filesystem 100 | */ 101 | async writeFiles(files: SandboxFile[]): Promise { 102 | console.log(`✓ Writing ${files.length} file(s) to local filesystem...`); 103 | 104 | for (const file of files) { 105 | const content = file.content.toString("utf-8"); 106 | 107 | // Handle paths that are already absolute and within workspace 108 | let fullPath: string; 109 | if (path.isAbsolute(file.path)) { 110 | // If the path is already absolute and starts with workspace path, use it directly 111 | if (file.path.startsWith(this.workspacePath)) { 112 | fullPath = file.path; 113 | } else { 114 | // If absolute but not in workspace, make it relative and join 115 | fullPath = path.join(this.workspacePath, file.path.substring(1)); 116 | } 117 | } else { 118 | // Relative path - join with workspace 119 | fullPath = path.join(this.workspacePath, file.path); 120 | } 121 | 122 | console.log(` Writing: ${fullPath} (${content.length} bytes)`); 123 | 124 | // Ensure directory exists 125 | const dirPath = path.dirname(fullPath); 126 | if (!fs.existsSync(dirPath)) { 127 | console.log(` Creating directory: ${dirPath}`); 128 | fs.mkdirSync(dirPath, { recursive: true }); 129 | } 130 | 131 | // Write the file 132 | fs.writeFileSync(fullPath, content, "utf-8"); 133 | console.log(` ✓ Written: ${fullPath}`); 134 | } 135 | 136 | console.log(`✓ Files written successfully`); 137 | } 138 | 139 | /** 140 | * Execute a command locally using Node.js child_process 141 | */ 142 | async runCommand(command: SandboxCommand): Promise { 143 | const fullCommand = [command.cmd, ...command.args].join(" "); 144 | 145 | try { 146 | // Use spawn for better output handling and long-running commands 147 | const result = await new Promise<{ 148 | exitCode: number; 149 | stdout: string; 150 | stderr: string; 151 | }>((resolve, reject) => { 152 | const proc = spawn(command.cmd, command.args, { 153 | cwd: this.cwd, 154 | shell: true, 155 | env: { ...process.env }, 156 | }); 157 | 158 | let stdout = ""; 159 | let stderr = ""; 160 | 161 | proc.stdout?.on("data", (data) => { 162 | const chunk = data.toString(); 163 | stdout += chunk; 164 | // Don't write to stdout - it pollutes the output 165 | }); 166 | 167 | proc.stderr?.on("data", (data) => { 168 | const chunk = data.toString(); 169 | stderr += chunk; 170 | // Don't write to stderr - it pollutes the output 171 | }); 172 | 173 | proc.on("close", (code) => { 174 | resolve({ 175 | exitCode: code || 0, 176 | stdout, 177 | stderr, 178 | }); 179 | }); 180 | 181 | proc.on("error", (error) => { 182 | reject(error); 183 | }); 184 | }); 185 | 186 | return new LocalCommandResult( 187 | result.exitCode, 188 | result.stdout, 189 | result.stderr 190 | ); 191 | } catch (error) { 192 | console.error(` Error executing command: ${error}`); 193 | throw error; 194 | } 195 | } 196 | 197 | /** 198 | * Cleanup the sandbox (no-op for local, files remain for inspection) 199 | */ 200 | async stop(): Promise { 201 | console.log( 202 | `✓ Local sandbox stopped (files preserved at: ${this.workspacePath})` 203 | ); 204 | } 205 | 206 | /** 207 | * Get the unique sandbox identifier 208 | */ 209 | getId(): string { 210 | return this.sandboxId; 211 | } 212 | 213 | /** 214 | * Get the workspace directory path 215 | */ 216 | getWorkspacePath(): string { 217 | return this.workspacePath; 218 | } 219 | 220 | /** 221 | * Set the working directory for subsequent commands 222 | */ 223 | setWorkingDirectory(path: string): void { 224 | this.cwd = path; 225 | } 226 | 227 | /** 228 | * Get the absolute path for inspection 229 | */ 230 | getAbsolutePath(relativePath?: string): string { 231 | if (relativePath) { 232 | return path.join(this.workspacePath, relativePath); 233 | } 234 | return this.workspacePath; 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/e2b-sandbox-provider.ts: -------------------------------------------------------------------------------- 1 | // E2B Sandbox provider implementation 2 | 3 | import { Sandbox } from "@e2b/code-interpreter"; 4 | import type { 5 | CommandResult, 6 | SandboxCommand, 7 | SandboxFile, 8 | SandboxProvider, 9 | SandboxProviderOptions, 10 | } from "./sandbox-provider.js"; 11 | 12 | /** 13 | * E2B-specific sandbox options 14 | */ 15 | export interface E2BSandboxOptions extends SandboxProviderOptions { 16 | apiKey?: string; 17 | template?: string; // E2B template ID, defaults to base Node.js template 18 | metadata?: Record; 19 | cwd?: string; // Working directory for commands 20 | } 21 | 22 | /** 23 | * Command result implementation for E2B 24 | */ 25 | class E2BCommandResult implements CommandResult { 26 | constructor( 27 | public exitCode: number, 28 | private stdoutContent: string, 29 | private stderrContent: string 30 | ) {} 31 | 32 | async stdout(): Promise { 33 | return this.stdoutContent; 34 | } 35 | 36 | async stderr(): Promise { 37 | return this.stderrContent; 38 | } 39 | } 40 | 41 | /** 42 | * E2B Sandbox provider implementation 43 | * Supports running TypeScript/Node.js code in isolated cloud sandboxes 44 | */ 45 | export class E2BSandboxProvider implements SandboxProvider { 46 | private sandbox: Sandbox; 47 | private workspacePath: string = "/home/user"; 48 | private cwd: string; 49 | private sandboxId: string; 50 | 51 | private constructor(sandbox: Sandbox, options: E2BSandboxOptions = {}) { 52 | this.sandbox = sandbox; 53 | this.sandboxId = sandbox.sandboxId; 54 | this.cwd = options.cwd || this.workspacePath; 55 | } 56 | 57 | /** 58 | * Create a new E2B sandbox instance 59 | */ 60 | static async create( 61 | options: E2BSandboxOptions = {} 62 | ): Promise { 63 | console.log( 64 | `✓ Creating E2B Sandbox (template: ${options.template || "base"})` 65 | ); 66 | 67 | const config: any = { 68 | apiKey: options.apiKey || process.env.E2B_API_KEY, 69 | timeoutMs: options.timeout || 1800000, // 30 minutes default 70 | }; 71 | 72 | if (options.template) { 73 | config.template = options.template; 74 | } 75 | 76 | if (options.metadata) { 77 | config.metadata = options.metadata; 78 | } 79 | 80 | const sandbox = await Sandbox.create(config); 81 | 82 | console.log(`✓ E2B Sandbox created (ID: ${sandbox.sandboxId})`); 83 | return new E2BSandboxProvider(sandbox, options); 84 | } 85 | 86 | /** 87 | * Write multiple files to the sandbox 88 | */ 89 | async writeFiles(files: SandboxFile[]): Promise { 90 | console.log(`✓ Writing ${files.length} file(s) to E2B sandbox...`); 91 | 92 | for (const file of files) { 93 | const content = file.content.toString("utf-8"); 94 | const fullPath = file.path.startsWith("/") 95 | ? file.path 96 | : `${this.workspacePath}/${file.path}`; 97 | 98 | console.log(` Writing: ${fullPath} (${content.length} bytes)`); 99 | 100 | // Ensure directory exists 101 | const dirPath = fullPath.substring(0, fullPath.lastIndexOf("/")); 102 | if (dirPath && dirPath !== this.workspacePath) { 103 | console.log(` Creating directory: ${dirPath}`); 104 | await this.sandbox.commands.run(`mkdir -p ${dirPath}`); 105 | } 106 | 107 | // Write the file 108 | await this.sandbox.files.write(fullPath, content); 109 | console.log(` ✓ Written: ${fullPath}`); 110 | } 111 | 112 | console.log(`✓ Files written successfully`); 113 | } 114 | 115 | /** 116 | * Execute a command in the sandbox 117 | */ 118 | async runCommand(command: SandboxCommand): Promise { 119 | const fullCommand = [command.cmd, ...command.args].join(" "); 120 | console.log(`✓ Executing command: ${fullCommand}`); 121 | console.log(` Working directory: ${this.cwd}`); 122 | 123 | // E2B has a separate timeout for command execution 124 | // Set it to 5 minutes for long-running operations like MCP calls 125 | const result = await this.sandbox.commands.run(fullCommand, { 126 | cwd: this.cwd, 127 | timeoutMs: 300000, // 5 minutes 128 | }); 129 | 130 | console.log(` Exit code: ${result.exitCode}`); 131 | if (result.stdout) { 132 | console.log(` Stdout length: ${result.stdout.length} chars`); 133 | if (result.stdout.length < 500) { 134 | console.log(` Stdout: ${result.stdout}`); 135 | } 136 | } 137 | if (result.stderr) { 138 | console.log(` Stderr length: ${result.stderr.length} chars`); 139 | if (result.stderr.length > 0) { 140 | console.log(` Stderr: ${result.stderr}`); 141 | } 142 | } 143 | 144 | return new E2BCommandResult(result.exitCode, result.stdout, result.stderr); 145 | } 146 | 147 | /** 148 | * Stop and cleanup the sandbox 149 | */ 150 | async stop(): Promise { 151 | console.log(`✓ Stopping E2B sandbox (ID: ${this.sandboxId})`); 152 | await this.sandbox.kill(); 153 | console.log("✓ E2B sandbox stopped"); 154 | } 155 | 156 | /** 157 | * Get the unique sandbox identifier 158 | */ 159 | getId(): string { 160 | return this.sandboxId; 161 | } 162 | 163 | /** 164 | * Get the workspace directory path 165 | */ 166 | getWorkspacePath(): string { 167 | return this.workspacePath; 168 | } 169 | 170 | /** 171 | * Set the working directory for subsequent commands 172 | */ 173 | setWorkingDirectory(path: string): void { 174 | this.cwd = path; 175 | } 176 | 177 | /** 178 | * Get the underlying E2B sandbox instance (for advanced use cases) 179 | */ 180 | getE2BSandbox(): Sandbox { 181 | return this.sandbox; 182 | } 183 | 184 | /** 185 | * Install npm packages in the sandbox 186 | */ 187 | async installPackages(packages: string[]): Promise { 188 | console.log(`✓ Installing npm packages: ${packages.join(", ")}`); 189 | return await this.runCommand({ 190 | cmd: "npm", 191 | args: ["install", ...packages], 192 | }); 193 | } 194 | 195 | /** 196 | * Run a TypeScript file using ts-node 197 | */ 198 | async runTypeScript(filePath: string): Promise { 199 | console.log(`✓ Running TypeScript file: ${filePath}`); 200 | 201 | // Check if file exists 202 | try { 203 | const checkResult = await this.sandbox.commands.run( 204 | `test -f ${filePath} && echo "exists" || echo "not found"` 205 | ); 206 | console.log(` File check: ${checkResult.stdout}`); 207 | } catch (err) { 208 | console.log(` Warning: Could not check file existence: ${err}`); 209 | } 210 | 211 | // First, ensure ts-node and typescript are installed 212 | const installResult = await this.installPackages([ 213 | "typescript", 214 | "ts-node", 215 | "@types/node", 216 | ]); 217 | if (installResult.exitCode !== 0) { 218 | console.log(` Warning: Package installation had non-zero exit code`); 219 | } 220 | 221 | return await this.runCommand({ 222 | cmd: "npx", 223 | args: ["ts-node", filePath], 224 | }); 225 | } 226 | 227 | /** 228 | * Read a file from the sandbox 229 | */ 230 | async readFile(path: string): Promise { 231 | const fullPath = path.startsWith("/") 232 | ? path 233 | : `${this.workspacePath}/${path}`; 234 | return await this.sandbox.files.read(fullPath); 235 | } 236 | 237 | /** 238 | * List files in a directory 239 | */ 240 | async listFiles(path: string = this.workspacePath): Promise { 241 | const result = await this.sandbox.commands.run(`ls -1 ${path}`); 242 | return result.stdout.split("\n").filter((f) => f.trim().length > 0); 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /tests/boundary.spec.ts: -------------------------------------------------------------------------------- 1 | import type { ModelMessage } from "ai"; 2 | import assert from "node:assert/strict"; 3 | import { 4 | compact, 5 | type Boundary, 6 | type FileAdapter, 7 | type FileWriteParams, 8 | type FileWriteResult, 9 | } from "../src/index"; 10 | 11 | class MemoryFileAdapter implements FileAdapter { 12 | public writes: { key: string; body: string; contentType?: string }[] = []; 13 | private prefix: string; 14 | constructor(prefix = "mem") { 15 | this.prefix = prefix; 16 | } 17 | async write(params: FileWriteParams): Promise { 18 | const bodyStr = 19 | typeof params.body === "string" 20 | ? params.body 21 | : new TextDecoder().decode(params.body); 22 | this.writes.push({ 23 | key: params.key, 24 | body: bodyStr, 25 | contentType: params.contentType, 26 | }); 27 | return { key: params.key }; 28 | } 29 | resolveKey(name: string): string { 30 | return `${this.prefix}/${name}`; 31 | } 32 | toString(): string { 33 | return `file:///${this.prefix}`; 34 | } 35 | } 36 | 37 | function makeConversation(): ModelMessage[] { 38 | // Larger synthetic conversation with multiple tool results across turns 39 | return [ 40 | // 0 41 | { role: "system", content: "You are helpful." }, 42 | // 1 43 | { role: "user", content: "Start by fetching A" }, 44 | // 2 - assistant tool-call (no text) 45 | { 46 | role: "assistant", 47 | content: [ 48 | { 49 | type: "tool-call", 50 | toolName: "fetchData", 51 | args: { query: "A" }, 52 | }, 53 | ], 54 | } as any, 55 | // 3 - tool result (will be compacted depending on boundary) 56 | { 57 | role: "tool", 58 | content: [ 59 | { 60 | type: "tool-result", 61 | toolName: "fetchData", 62 | result: "ok", 63 | output: { type: "json", value: { a: 1 } }, 64 | }, 65 | ], 66 | } as any, 67 | // 4 68 | { role: "assistant", content: "Fetched A" }, 69 | // 5 70 | { role: "user", content: "Read file B" }, 71 | // 6 - assistant tool-call for readFile 72 | { 73 | role: "assistant", 74 | content: [ 75 | { 76 | type: "tool-call", 77 | toolName: "readFile", 78 | args: { path: "tmp/data.txt" }, 79 | }, 80 | ], 81 | } as any, 82 | // 7 - reader tool (should be replaced with reference text when in window) 83 | { 84 | role: "tool", 85 | content: [ 86 | { 87 | type: "tool-result", 88 | toolName: "readFile", 89 | result: "ok", 90 | output: { 91 | type: "json", 92 | value: { 93 | storage: "file:///tmp", 94 | key: "tmp/data.txt", 95 | fileName: "data.txt", 96 | }, 97 | }, 98 | }, 99 | ], 100 | } as any, 101 | // 8 102 | { role: "assistant", content: "Read B" }, 103 | // 9 104 | { role: "user", content: "Fetch C" }, 105 | // 10 - assistant tool-call for fetch C 106 | { 107 | role: "assistant", 108 | content: [ 109 | { 110 | type: "tool-call", 111 | toolName: "fetchData", 112 | args: { query: "C" }, 113 | }, 114 | ], 115 | } as any, 116 | // 11 - tool result (later one) 117 | { 118 | role: "tool", 119 | content: [ 120 | { 121 | type: "tool-result", 122 | toolName: "fetchData", 123 | result: "ok", 124 | output: { type: "json", value: { c: 3 } }, 125 | }, 126 | ], 127 | } as any, 128 | // 12 (final, required by strategy to trigger compaction) 129 | { role: "assistant", content: "All done" }, 130 | ]; 131 | } 132 | 133 | async function run(boundary: Boundary) { 134 | const adapter = new MemoryFileAdapter("test"); 135 | const messages = makeConversation(); 136 | const compacted = await compact(messages, { 137 | storage: adapter, 138 | boundary, 139 | }); 140 | // eslint-disable-next-line no-console 141 | console.log("\n=== Boundary PRE ===\n", JSON.stringify(boundary, null, 2)); 142 | // eslint-disable-next-line no-console 143 | console.log(JSON.stringify(messages, null, 2)); 144 | // eslint-disable-next-line no-console 145 | console.log("\n=== Boundary POST ===\n", JSON.stringify(boundary, null, 2)); 146 | // eslint-disable-next-line no-console 147 | console.log(JSON.stringify(compacted, null, 2)); 148 | return { compacted, adapter }; 149 | } 150 | 151 | async function testSinceLastAssistantOrUserText() { 152 | const { compacted, adapter } = await run("all"); 153 | // Window starts after last user/assistant text (index 9), so only index 11 is compacted. 154 | assert.equal(adapter.writes.length, 1); 155 | // Index 3 remains JSON (older fetch) 156 | { 157 | const t = compacted[3] as any; 158 | const p = t.content[0]; 159 | assert.equal(p.output.type, "json"); 160 | } 161 | // Index 7 (readFile tool result) remains JSON (outside window) 162 | { 163 | const t = compacted[7] as any; 164 | const p = t.content[0]; 165 | assert.equal(p.output.type, "json"); 166 | } 167 | // Index 11 is written and replaced with text reference 168 | { 169 | const t = compacted[11] as any; 170 | const p = t.content[0]; 171 | assert.equal(p.type, "tool-result"); 172 | assert.equal(p.output.type, "text"); 173 | assert.match(p.output.value, /Written to file:/); 174 | } 175 | } 176 | 177 | async function testEntireConversation() { 178 | const { compacted, adapter } = await run("all"); 179 | // All tool results before final assistant are processed: indices 3(fetchData),7(readFile),11(fetchData) 180 | // Only the two fetchData results are written; readFile is reference-only. 181 | assert.equal(adapter.writes.length, 2); 182 | // Index 2 written 183 | { 184 | const t = compacted[3] as any; 185 | const p = t.content[0]; 186 | assert.equal(p.output.type, "text"); 187 | assert.match(p.output.value, /Written to file:/); 188 | } 189 | // Index 5 shows reference to file, not a write 190 | { 191 | const t = compacted[7] as any; 192 | const p = t.content[0]; 193 | assert.equal(p.output.type, "text"); 194 | assert.match(p.output.value, /Read from file:/); 195 | } 196 | // Index 8 written 197 | { 198 | const t = compacted[11] as any; 199 | const p = t.content[0]; 200 | assert.equal(p.output.type, "text"); 201 | assert.match(p.output.value, /Written to file:/); 202 | } 203 | } 204 | 205 | async function testFirstNMessages() { 206 | // Preserve the latest 3 messages; compact the older ones. 207 | // Latest 3 are indices 10(assistant tool-call), 11(tool fetchData), 12(assistant). So we compact [0..10). 208 | const { compacted, adapter } = await run({ 209 | type: "keep-last", 210 | count: 3, 211 | }); 212 | // One write expected: index 3 (fetchData). Index 7 (readFile) becomes reference. Index 11 is preserved. 213 | assert.equal(adapter.writes.length, 1); 214 | // Index 2 is written (text) 215 | { 216 | const t = compacted[3] as any; 217 | const p = t.content[0]; 218 | assert.equal(p.output.type, "text"); 219 | assert.match(p.output.value, /Written to file:/); 220 | } 221 | // Index 5 becomes a reference text (readFile inside window) 222 | { 223 | const t = compacted[7] as any; 224 | const p = t.content[0]; 225 | assert.equal(p.output.type, "text"); 226 | assert.match(p.output.value, /Read from file:/); 227 | } 228 | // Index 11 remains JSON (preserved due to latest N) 229 | { 230 | const t = compacted[11] as any; 231 | const p = t.content[0]; 232 | assert.equal(p.output.type, "json"); 233 | } 234 | } 235 | 236 | (async () => { 237 | await testSinceLastAssistantOrUserText(); 238 | await testEntireConversation(); 239 | await testFirstNMessages(); 240 | // eslint-disable-next-line no-console 241 | console.log("Boundary tests passed"); 242 | })().catch((err) => { 243 | // eslint-disable-next-line no-console 244 | console.error(err); 245 | process.exit(1); 246 | }); 247 | -------------------------------------------------------------------------------- /examples/tools/weather_tool_sandbox.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Weather Tool Sandbox Demo 3 | * 4 | * This example shows how to turn an AI SDK tool into source code inside a sandbox 5 | * using the unified `SandboxExplorer`. It supports all three sandbox providers shipped 6 | * with ctx-zip (local filesystem, Vercel Sandbox, and E2B). 7 | * 8 | * Usage: 9 | * npm run example:tools-weather -- --provider=local 10 | * npm run example:tools-weather -- --provider=vercel 11 | * npm run example:tools-weather -- --provider=e2b 12 | * npm run example:tools-weather -- --provider=all # run sequentially 13 | * 14 | * Requirements: 15 | * - Local sandbox: no additional setup. 16 | * - Vercel sandbox: requires `@vercel/sandbox` optional dependency and Vercel login. 17 | * - E2B sandbox: install `@e2b/code-interpreter` and set E2B_API_KEY. 18 | */ 19 | 20 | import { tool } from "ai"; 21 | import { z } from "zod"; 22 | 23 | import { SandboxManager } from "../../src/sandbox-code-generator/sandbox-manager.js"; 24 | import type { SandboxProvider } from "../../src/sandbox-code-generator/sandbox-provider.js"; 25 | import type { ToolCodeGenerationResult } from "../../src/sandbox-code-generator/tool-code-writer.js"; 26 | 27 | type ProviderName = "local" | "vercel" | "e2b"; 28 | type ProviderSelection = ProviderName | "all"; 29 | 30 | const args = process.argv.slice(2); 31 | 32 | function getArgValue(flag: string): string | undefined { 33 | const prefix = `--${flag}=`; 34 | const match = args.find((arg) => arg.startsWith(prefix)); 35 | return match ? match.slice(prefix.length) : undefined; 36 | } 37 | 38 | const providerArg = (getArgValue("provider") ?? "local") as ProviderSelection; 39 | const locationArg = getArgValue("location") ?? "San Francisco"; 40 | const sampleLocation = locationArg; 41 | 42 | const providerSequence: ProviderName[] = 43 | providerArg === "all" 44 | ? ["local", "vercel", "e2b"] 45 | : [validateProvider(providerArg)]; 46 | 47 | // Weather tool taken from the AI SDK tool calling documentation. 48 | const weatherTool = tool({ 49 | description: "Get the weather in a location", 50 | inputSchema: z.object({ 51 | location: z.string().describe("The location to get the weather for"), 52 | }), 53 | async execute({ location }: { location: string }) { 54 | const temperature = 72 + Math.floor(Math.random() * 21) - 10; 55 | return { 56 | location, 57 | temperature, 58 | units: "°F", 59 | generatedAt: new Date().toISOString(), 60 | }; 61 | }, 62 | }); 63 | 64 | async function main() { 65 | console.log( 66 | `\n🌤️ Weather tool demo (location: ${sampleLocation}, provider(s): ${providerSequence.join( 67 | ", " 68 | )})\n` 69 | ); 70 | 71 | for (const providerName of providerSequence) { 72 | console.log(`\n=== ${providerName.toUpperCase()} SANDBOX ===`); 73 | 74 | let sandboxProvider: SandboxProvider | undefined; 75 | let manager: SandboxManager | undefined; 76 | 77 | try { 78 | sandboxProvider = await createProvider(providerName); 79 | manager = await SandboxManager.create({ 80 | sandboxProvider, 81 | }); 82 | 83 | await manager.register({ 84 | standardTools: { 85 | weather: weatherTool, 86 | }, 87 | standardToolOptions: { 88 | title: "Weather Agent Tool", 89 | }, 90 | }); 91 | 92 | await runWeatherDemo(manager, providerName, sampleLocation); 93 | } catch (error) { 94 | console.error( 95 | `✗ Error while running demo in ${providerName} sandbox: ${ 96 | error instanceof Error ? error.message : String(error) 97 | }` 98 | ); 99 | if (providerName === "e2b") { 100 | console.error( 101 | " • Make sure @e2b/code-interpreter is installed and E2B_API_KEY is set." 102 | ); 103 | } 104 | if (providerName === "vercel") { 105 | console.error( 106 | " • Ensure @vercel/sandbox is installed and you have access to Vercel Sandbox." 107 | ); 108 | } 109 | } finally { 110 | try { 111 | if (manager) { 112 | await manager.cleanup(); 113 | } else if (sandboxProvider) { 114 | await sandboxProvider.stop(); 115 | } 116 | } catch (stopError) { 117 | console.error( 118 | `⚠️ Error while stopping ${providerName} sandbox: ${ 119 | stopError instanceof Error ? stopError.message : String(stopError) 120 | }` 121 | ); 122 | } 123 | } 124 | } 125 | 126 | console.log("\n✅ Weather tool demo complete.\n"); 127 | } 128 | 129 | function validateProvider(provider: ProviderSelection): ProviderName { 130 | if (provider === "all") { 131 | return "local"; 132 | } 133 | if (provider === "local" || provider === "vercel" || provider === "e2b") { 134 | return provider; 135 | } 136 | throw new Error( 137 | `Unknown provider "${provider}". Expected one of: local, vercel, e2b, all.` 138 | ); 139 | } 140 | 141 | async function createProvider( 142 | provider: ProviderName 143 | ): Promise { 144 | switch (provider) { 145 | case "local": { 146 | const { LocalSandboxProvider } = await import( 147 | "../../src/sandbox-code-generator/local-sandbox-provider.js" 148 | ); 149 | return await LocalSandboxProvider.create({ 150 | sandboxDir: "./.sandbox-weather", 151 | cleanOnCreate: true, 152 | }); 153 | } 154 | case "vercel": { 155 | const { VercelSandboxProvider } = await import( 156 | "../../src/sandbox-code-generator/vercel-sandbox-provider.js" 157 | ); 158 | return await VercelSandboxProvider.create(); 159 | } 160 | case "e2b": { 161 | const { E2BSandboxProvider } = await import( 162 | "../../src/sandbox-code-generator/e2b-sandbox-provider.js" 163 | ); 164 | return await E2BSandboxProvider.create(); 165 | } 166 | default: 167 | throw new Error(`Unsupported provider: ${provider satisfies never}`); 168 | } 169 | } 170 | 171 | async function runWeatherDemo( 172 | manager: SandboxManager, 173 | providerName: ProviderName, 174 | location: string 175 | ) { 176 | const provider = manager.getSandboxProvider(); 177 | console.log(`→ Workspace path: ${provider.getWorkspacePath()}`); 178 | 179 | const generationResult: ToolCodeGenerationResult | undefined = 180 | manager.getStandardToolsResult(); 181 | 182 | if (!generationResult) { 183 | console.warn("⚠️ No standard tools were generated."); 184 | return; 185 | } 186 | 187 | console.log("→ Generated files:"); 188 | generationResult.files.forEach((file) => console.log(` • ${file}`)); 189 | 190 | await showDirectoryTree(provider, generationResult.outputDir); 191 | 192 | const toolFile = generationResult.files.find( 193 | (file) => 194 | file.endsWith(".ts") && 195 | !file.endsWith("index.ts") && 196 | !file.endsWith("README.md") 197 | ); 198 | 199 | if (toolFile) { 200 | console.log(`\n→ Preview of ${toolFile}:`); 201 | await printFileSnippet(provider, toolFile, 40); 202 | } 203 | 204 | const sampleResult = await weatherTool.execute?.( 205 | { location }, 206 | { toolCallId: "weather-demo", messages: [] } 207 | ); 208 | 209 | if (sampleResult) { 210 | console.log("\n→ Sample tool result:"); 211 | console.log(JSON.stringify(sampleResult, null, 2)); 212 | } 213 | 214 | console.log( 215 | `\n✓ Weather tool code ready inside ${providerName} sandbox: ${generationResult.outputDir}\n` 216 | ); 217 | } 218 | 219 | async function showDirectoryTree( 220 | provider: SandboxProvider, 221 | directory: string 222 | ): Promise { 223 | console.log(`\n→ Directory listing for ${directory}:`); 224 | const lsResult = await provider.runCommand({ 225 | cmd: "ls", 226 | args: ["-R", directory], 227 | }); 228 | const lsOutput = await lsResult.stdout(); 229 | console.log(lsOutput || "(empty directory)"); 230 | } 231 | 232 | async function printFileSnippet( 233 | provider: SandboxProvider, 234 | filePath: string, 235 | maxLines: number 236 | ): Promise { 237 | const snippetResult = await provider.runCommand({ 238 | cmd: "head", 239 | args: [`-n${maxLines}`, filePath], 240 | }); 241 | 242 | const snippetOutput = await snippetResult.stdout(); 243 | console.log(snippetOutput || "(no content)"); 244 | } 245 | 246 | main().catch((error) => { 247 | console.error( 248 | `\n❌ Weather tool demo failed: ${ 249 | error instanceof Error ? error.message : String(error) 250 | }\n` 251 | ); 252 | process.exit(1); 253 | }); 254 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/sandbox-utils.ts: -------------------------------------------------------------------------------- 1 | // Utility functions for quickly setting up sandboxes in code mode 2 | // Code mode transforms MCP servers and tools into executable code that runs in the sandbox 3 | 4 | import type { Tool } from "ai"; 5 | import { 6 | LocalSandboxProvider, 7 | type LocalSandboxOptions, 8 | } from "./local-sandbox-provider.js"; 9 | import { SandboxManager } from "./sandbox-manager.js"; 10 | import type { ToolCodeGenerationOptions } from "./tool-code-writer.js"; 11 | import type { MCPServerConfig } from "./types.js"; 12 | import { 13 | VercelSandboxProvider, 14 | type VercelSandboxOptions, 15 | } from "./vercel-sandbox-provider.js"; 16 | 17 | // Lazy import types - these are only imported when needed 18 | import type { E2BSandboxOptions } from "./e2b-sandbox-provider.js"; 19 | 20 | /** 21 | * Options for creating a sandbox in code mode 22 | * Code mode transforms MCP servers and tools into executable code that runs in the sandbox 23 | */ 24 | export interface SandboxCodeModeOptions { 25 | /** 26 | * Sandbox provider options (specific to each provider type) 27 | * Optional - sensible defaults are applied automatically 28 | */ 29 | sandboxOptions?: 30 | | VercelSandboxOptions 31 | | E2BSandboxOptions 32 | | LocalSandboxOptions; 33 | /** 34 | * MCP servers to register 35 | */ 36 | servers?: MCPServerConfig[]; 37 | /** 38 | * Standard AI SDK tools to register 39 | */ 40 | standardTools?: Record>; 41 | /** 42 | * Options for standard tool code generation 43 | */ 44 | standardToolOptions?: ToolCodeGenerationOptions; 45 | } 46 | 47 | /** 48 | * Result of creating a sandbox in code mode 49 | */ 50 | export interface SandboxCodeModeResult { 51 | /** 52 | * All available tools (exploration + execution) 53 | */ 54 | tools: Record>; 55 | /** 56 | * The sandbox manager instance (for cleanup and advanced operations) 57 | */ 58 | manager: SandboxManager; 59 | } 60 | 61 | /** 62 | * Create a Vercel sandbox in code mode 63 | * Transforms MCP servers and tools into executable code that runs in the sandbox 64 | * 65 | * Default sandbox settings: 66 | * - timeout: 1800000ms (30 minutes) 67 | * - runtime: "node22" 68 | * - vcpus: 4 69 | * 70 | * @param options - Configuration options (sandboxOptions are optional with sensible defaults) 71 | * @returns Tools and manager instance 72 | * 73 | * @example 74 | * ```typescript 75 | * // Simple usage with defaults 76 | * const { tools, manager } = await createVercelSandboxCodeMode({ 77 | * servers: [ 78 | * { 79 | * name: "vercel", 80 | * url: "https://mcp.vercel.com", 81 | * headers: { Authorization: `Bearer ${process.env.VERCEL_API_KEY}` }, 82 | * }, 83 | * ], 84 | * }); 85 | * 86 | * // Custom sandbox options (optional) 87 | * const { tools, manager } = await createVercelSandboxCodeMode({ 88 | * sandboxOptions: { 89 | * timeout: 3600000, // 1 hour 90 | * vcpus: 8, 91 | * }, 92 | * servers: [...], 93 | * }); 94 | * ``` 95 | */ 96 | export async function createVercelSandboxCodeMode( 97 | options: SandboxCodeModeOptions = {} 98 | ): Promise { 99 | const { 100 | sandboxOptions = {}, 101 | servers = [], 102 | standardTools = {}, 103 | standardToolOptions, 104 | } = options; 105 | 106 | // Validate required environment variable 107 | if (!process.env.VERCEL_OIDC_TOKEN) { 108 | throw new Error( 109 | "VERCEL_OIDC_TOKEN environment variable is required for Vercel sandbox. Please set it before creating a Vercel sandbox." 110 | ); 111 | } 112 | 113 | // Create Vercel sandbox provider with defaults 114 | const sandboxProvider = await VercelSandboxProvider.create({ 115 | timeout: 1800000, // 30 minutes default 116 | runtime: "node22", 117 | vcpus: 4, 118 | ...sandboxOptions, 119 | } as VercelSandboxOptions); 120 | 121 | // Initialize SandboxManager 122 | const manager = await SandboxManager.create({ 123 | sandboxProvider, 124 | }); 125 | 126 | // Register servers and standard tools 127 | if (servers.length > 0 || Object.keys(standardTools).length > 0) { 128 | await manager.register({ 129 | servers, 130 | standardTools, 131 | standardToolOptions, 132 | }); 133 | } 134 | 135 | // Get all tools 136 | const tools = manager.getAllTools(); 137 | 138 | return { tools, manager }; 139 | } 140 | 141 | /** 142 | * Create an E2B sandbox in code mode 143 | * Transforms MCP servers and tools into executable code that runs in the sandbox 144 | * 145 | * Default sandbox settings: 146 | * - timeout: 1800000ms (30 minutes) 147 | * 148 | * @param options - Configuration options (sandboxOptions are optional with sensible defaults) 149 | * @returns Tools and manager instance 150 | * 151 | * @example 152 | * ```typescript 153 | * // Simple usage with defaults 154 | * const { tools, manager } = await createE2BSandboxCodeMode({ 155 | * servers: [ 156 | * { 157 | * name: "my-server", 158 | * url: "https://mcp.example.com", 159 | * }, 160 | * ], 161 | * }); 162 | * 163 | * // Custom sandbox options (optional) 164 | * const { tools, manager } = await createE2BSandboxCodeMode({ 165 | * sandboxOptions: { 166 | * timeout: 3600000, 167 | * template: "base", 168 | * }, 169 | * servers: [...], 170 | * }); 171 | * ``` 172 | */ 173 | export async function createE2BSandboxCodeMode( 174 | options: SandboxCodeModeOptions = {} 175 | ): Promise { 176 | const { 177 | sandboxOptions = {}, 178 | servers = [], 179 | standardTools = {}, 180 | standardToolOptions, 181 | } = options; 182 | 183 | // Lazy import E2B provider to avoid loading it when not needed 184 | const { E2BSandboxProvider } = await import("./e2b-sandbox-provider.js"); 185 | 186 | // Validate required environment variable (check both options and env) 187 | const apiKey = 188 | (sandboxOptions as E2BSandboxOptions).apiKey || process.env.E2B_API_KEY; 189 | if (!apiKey) { 190 | throw new Error( 191 | "E2B_API_KEY environment variable is required for E2B sandbox. Please set it before creating an E2B sandbox, or provide it via sandboxOptions.apiKey." 192 | ); 193 | } 194 | 195 | // Create E2B sandbox provider with defaults 196 | const sandboxProvider = await E2BSandboxProvider.create({ 197 | timeout: 1800000, // 30 minutes default 198 | ...sandboxOptions, 199 | } as E2BSandboxOptions); 200 | 201 | // Initialize SandboxManager 202 | const manager = await SandboxManager.create({ 203 | sandboxProvider, 204 | }); 205 | 206 | // Register servers and standard tools 207 | if (servers.length > 0 || Object.keys(standardTools).length > 0) { 208 | await manager.register({ 209 | servers, 210 | standardTools, 211 | standardToolOptions, 212 | }); 213 | } 214 | 215 | // Get all tools 216 | const tools = manager.getAllTools(); 217 | 218 | return { tools, manager }; 219 | } 220 | 221 | /** 222 | * Create a local sandbox in code mode 223 | * Transforms MCP servers and tools into executable code that runs in the sandbox 224 | * 225 | * Default sandbox settings: 226 | * - sandboxDir: "./.sandbox" 227 | * - cleanOnCreate: true 228 | * 229 | * @param options - Configuration options (sandboxOptions are optional with sensible defaults) 230 | * @returns Tools and manager instance 231 | * 232 | * @example 233 | * ```typescript 234 | * // Simple usage with defaults 235 | * const { tools, manager } = await createLocalSandboxCodeMode({ 236 | * servers: [ 237 | * { 238 | * name: "my-server", 239 | * url: "https://mcp.example.com", 240 | * }, 241 | * ], 242 | * }); 243 | * 244 | * // Custom sandbox options (optional) 245 | * const { tools, manager } = await createLocalSandboxCodeMode({ 246 | * sandboxOptions: { 247 | * sandboxDir: "./custom-sandbox", 248 | * cleanOnCreate: false, 249 | * }, 250 | * servers: [...], 251 | * }); 252 | * ``` 253 | */ 254 | export async function createLocalSandboxCodeMode( 255 | options: SandboxCodeModeOptions = {} 256 | ): Promise { 257 | const { 258 | sandboxOptions = {}, 259 | servers = [], 260 | standardTools = {}, 261 | standardToolOptions, 262 | } = options; 263 | 264 | // Create local sandbox provider with defaults 265 | const sandboxProvider = await LocalSandboxProvider.create({ 266 | sandboxDir: "./.sandbox", 267 | cleanOnCreate: true, 268 | ...sandboxOptions, 269 | } as LocalSandboxOptions); 270 | 271 | // Initialize SandboxManager 272 | const manager = await SandboxManager.create({ 273 | sandboxProvider, 274 | }); 275 | 276 | // Register servers and standard tools 277 | if (servers.length > 0 || Object.keys(standardTools).length > 0) { 278 | await manager.register({ 279 | servers, 280 | standardTools, 281 | standardToolOptions, 282 | }); 283 | } 284 | 285 | // Get all tools 286 | const tools = manager.getAllTools(); 287 | 288 | return { tools, manager }; 289 | } 290 | -------------------------------------------------------------------------------- /examples/mcp/vercel_mcp_search.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interactive GitHub Search Assistant with Vercel and grep.app 3 | * 4 | * This example demonstrates an interactive chat interface that uses: 5 | * - Vercel: Cloud sandbox provider (no additional setup required) 6 | * - MCP (Model Context Protocol) with grep.app for GitHub search 7 | * 8 | * Required Environment Variables: 9 | * - OPENAI_API_KEY (required) 10 | * 11 | * Usage: 12 | * npm run example:mcp-vercel 13 | * 14 | * Features: 15 | * - Interactive chat loop for exploring GitHub repositories 16 | * - Real-time tool execution tracking 17 | * - Token usage and cost tracking 18 | * - Persistent conversation history 19 | */ 20 | 21 | import { getTokenCosts } from "@tokenlens/helpers"; 22 | import { ModelMessage, stepCountIs, streamText } from "ai"; 23 | import dotenv from "dotenv"; 24 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; 25 | import path from "node:path"; 26 | import * as readline from "node:readline"; 27 | import { fetchModels } from "tokenlens"; 28 | import { SANDBOX_SYSTEM_PROMPT } from "../../src/sandbox-code-generator/prompts.js"; 29 | import { SandboxManager } from "../../src/sandbox-code-generator/sandbox-manager.js"; 30 | import { VercelSandboxProvider } from "../../src/sandbox-code-generator/vercel-sandbox-provider.js"; 31 | 32 | // Load environment variables 33 | dotenv.config(); 34 | 35 | // Stats interface to track conversation metrics 36 | interface ConversationStats { 37 | totalMessages: number; 38 | apiTokensInput: number; 39 | apiTokensOutput: number; 40 | apiTokensTotal: number; 41 | costUSD: number; 42 | toolCallsThisTurn: number; 43 | totalToolCalls: number; 44 | } 45 | 46 | /** 47 | * Validate required environment variables 48 | */ 49 | function validateEnvironment(): { valid: boolean; missing: string[] } { 50 | const missing: string[] = []; 51 | 52 | if (!process.env.OPENAI_API_KEY) { 53 | missing.push("OPENAI_API_KEY"); 54 | } 55 | 56 | return { 57 | valid: missing.length === 0, 58 | missing, 59 | }; 60 | } 61 | 62 | /** 63 | * Load messages from file 64 | */ 65 | function loadMessages(messagesFilePath: string): ModelMessage[] { 66 | if (existsSync(messagesFilePath)) { 67 | const raw = readFileSync(messagesFilePath, "utf-8"); 68 | return JSON.parse(raw); 69 | } 70 | return []; 71 | } 72 | 73 | /** 74 | * Save messages to file 75 | */ 76 | function saveMessages(messagesFilePath: string, messages: ModelMessage[]) { 77 | const dir = path.dirname(messagesFilePath); 78 | if (!existsSync(dir)) { 79 | mkdirSync(dir, { recursive: true }); 80 | } 81 | writeFileSync(messagesFilePath, JSON.stringify(messages, null, 2)); 82 | } 83 | 84 | async function main() { 85 | console.log("\n🚀 Interactive GitHub Search Assistant (Vercel)\n"); 86 | 87 | // Validate environment variables 88 | const validation = validateEnvironment(); 89 | 90 | if (!validation.valid) { 91 | console.error("❌ Missing required environment variables:"); 92 | validation.missing.forEach((varName) => { 93 | console.error(` - ${varName}`); 94 | }); 95 | console.error( 96 | "\nPlease set these variables in your .env file or environment.\n" 97 | ); 98 | process.exit(1); 99 | } 100 | 101 | console.log("✅ Environment validated\n"); 102 | 103 | // Initialize with Vercel sandbox 104 | console.log("🔧 Creating Vercel sandbox..."); 105 | const sandboxProvider = await VercelSandboxProvider.create({ 106 | timeout: 1800000, // 30 minutes 107 | runtime: "node22", 108 | vcpus: 4, 109 | }); 110 | 111 | // Initialize SandboxManager with Vercel provider 112 | console.log("🔧 Setting up sandbox manager..."); 113 | const manager = await SandboxManager.create({ 114 | sandboxProvider, 115 | }); 116 | 117 | // Register MCP tools 118 | console.log("🔧 Registering MCP tools (grep.app)..."); 119 | await manager.register({ 120 | servers: [ 121 | { 122 | name: "grep-app", 123 | url: "https://mcp.grep.app", 124 | }, 125 | ], 126 | }); 127 | 128 | // Get all tools 129 | const tools = manager.getAllTools(); 130 | 131 | const mcpDir = manager.getMcpDir(); 132 | const userCodeDir = manager.getUserCodeDir(); 133 | 134 | // Create session ID and messages file path 135 | const sessionId = `github-search-${new Date() 136 | .toISOString() 137 | .slice(0, 10)}-${Date.now().toString(36)}`; 138 | const storageDir = path.resolve(process.cwd(), ".sandbox-vercel"); 139 | const messagesFilePath = path.resolve( 140 | storageDir, 141 | sessionId, 142 | "conversation.json" 143 | ); 144 | 145 | // Load existing conversation 146 | let messages = loadMessages(messagesFilePath); 147 | 148 | // Fetch OpenAI provider data for token/cost calculations 149 | const openaiProvider = await fetchModels("openai"); 150 | 151 | // Initialize stats 152 | let stats: ConversationStats = { 153 | totalMessages: messages.length, 154 | apiTokensInput: 0, 155 | apiTokensOutput: 0, 156 | apiTokensTotal: 0, 157 | costUSD: 0, 158 | toolCallsThisTurn: 0, 159 | totalToolCalls: 0, 160 | }; 161 | 162 | console.log("\n" + "=".repeat(80)); 163 | console.log("🤖 Interactive GitHub Search Assistant"); 164 | console.log(`Session: ${sessionId}`); 165 | console.log(`Sandbox: Vercel | MCP Tools: grep.app`); 166 | console.log("=".repeat(80) + "\n"); 167 | 168 | if (messages.length > 0) { 169 | console.log( 170 | `📝 Loaded ${messages.length} messages from previous session\n` 171 | ); 172 | } 173 | 174 | console.log("💡 Tips:"); 175 | console.log( 176 | " - Ask me to search GitHub repositories for code, patterns, or implementations" 177 | ); 178 | console.log(" - I can read and analyze tool definitions in the sandbox"); 179 | console.log(" - Type 'exit' or 'quit' to end the session\n"); 180 | 181 | // Function to display stats 182 | function displayStats() { 183 | console.log("\n" + "-".repeat(80)); 184 | console.log("📊 Stats:"); 185 | console.log(` Messages: ${stats.totalMessages}`); 186 | console.log(` Tool Calls (this turn): ${stats.toolCallsThisTurn}`); 187 | console.log(` Total Tool Calls: ${stats.totalToolCalls}`); 188 | console.log(` Last API Call:`); 189 | console.log(` Input: ${stats.apiTokensInput} tokens`); 190 | console.log(` Output: ${stats.apiTokensOutput} tokens`); 191 | console.log(` Total: ${stats.apiTokensTotal} tokens`); 192 | console.log(` Cost: $${stats.costUSD.toFixed(6)}`); 193 | console.log("-".repeat(80) + "\n"); 194 | } 195 | 196 | // Create readline interface 197 | const rl = readline.createInterface({ 198 | input: process.stdin, 199 | output: process.stdout, 200 | prompt: "You: ", 201 | }); 202 | 203 | // Display initial stats 204 | displayStats(); 205 | 206 | // Cleanup function 207 | const cleanup = async () => { 208 | rl.close(); 209 | console.log("\n🧹 Cleaning up Vercel sandbox..."); 210 | await manager.cleanup(); 211 | console.log("✅ Done!\n"); 212 | }; 213 | 214 | // Main chat loop 215 | rl.prompt(); 216 | 217 | rl.on("line", async (line: string) => { 218 | const userInput = line.trim(); 219 | 220 | if (!userInput) { 221 | rl.prompt(); 222 | return; 223 | } 224 | 225 | // Check for exit command 226 | if ( 227 | userInput.toLowerCase() === "exit" || 228 | userInput.toLowerCase() === "quit" 229 | ) { 230 | await cleanup(); 231 | console.log("👋 Goodbye!"); 232 | process.exit(0); 233 | } 234 | 235 | // Display user message 236 | console.log(`\nYou: ${userInput}`); 237 | 238 | // Add user message to conversation 239 | messages.push({ 240 | role: "user", 241 | content: userInput, 242 | }); 243 | 244 | try { 245 | // Reset tool call counter 246 | stats.toolCallsThisTurn = 0; 247 | 248 | // Stream the response with tool call tracking 249 | const result = streamText({ 250 | model: "openai/gpt-4.1-mini", 251 | tools, 252 | stopWhen: stepCountIs(20), 253 | system: SANDBOX_SYSTEM_PROMPT, 254 | messages, 255 | onStepFinish: (step) => { 256 | const { toolCalls } = step; 257 | if (toolCalls && toolCalls.length > 0) { 258 | stats.toolCallsThisTurn += toolCalls.length; 259 | stats.totalToolCalls += toolCalls.length; 260 | 261 | console.log(`\n🔧 Tool Calls:`); 262 | toolCalls.forEach((call) => { 263 | const toolName = call.toolName; 264 | const args = (call as any).args || {}; 265 | console.log(` - ${toolName}`); 266 | const argsStr = JSON.stringify(args, null, 2); 267 | if (argsStr.length > 200) { 268 | console.log(` ${argsStr.substring(0, 200)}...`); 269 | } else { 270 | console.log(` ${argsStr}`); 271 | } 272 | }); 273 | console.log(""); 274 | } 275 | }, 276 | }); 277 | 278 | // Stream the assistant response in real-time 279 | process.stdout.write("Assistant: "); 280 | let streamedText = ""; 281 | 282 | for await (const textPart of result.textStream) { 283 | streamedText += textPart; 284 | process.stdout.write(textPart); 285 | } 286 | 287 | // Add final newline 288 | console.log("\n"); 289 | 290 | // Get the response and actual token usage 291 | const response = await result.response; 292 | const responseMessages = response.messages; 293 | const actualUsage = await result.usage; 294 | 295 | if (actualUsage && openaiProvider) { 296 | const modelId = "openai/gpt-4.1-mini"; 297 | const costs = getTokenCosts(modelId, actualUsage, openaiProvider); 298 | 299 | const inputTokens = actualUsage.inputTokens || 0; 300 | const outputTokens = actualUsage.outputTokens || 0; 301 | const totalTokens = 302 | actualUsage.totalTokens || inputTokens + outputTokens; 303 | 304 | stats.apiTokensInput = inputTokens; 305 | stats.apiTokensOutput = outputTokens; 306 | stats.apiTokensTotal = totalTokens; 307 | stats.costUSD = costs.totalUSD || 0; 308 | } 309 | 310 | // Append NEW response messages to the conversation 311 | for (const msg of responseMessages) { 312 | messages.push(msg); 313 | } 314 | 315 | // Update message count 316 | stats.totalMessages = messages.length; 317 | 318 | // Save to file 319 | saveMessages(messagesFilePath, messages); 320 | 321 | // Display updated stats 322 | displayStats(); 323 | } catch (error: any) { 324 | console.error(`\n❌ Error: ${error.message}\n`); 325 | } 326 | 327 | // Prompt for next input 328 | rl.prompt(); 329 | }); 330 | 331 | // Handle Ctrl+C to exit 332 | rl.on("SIGINT", async () => { 333 | await cleanup(); 334 | console.log("\n👋 Goodbye!"); 335 | process.exit(0); 336 | }); 337 | } 338 | 339 | main().catch(async (error) => { 340 | console.error("❌ Error:", error); 341 | process.exit(1); 342 | }); 343 | -------------------------------------------------------------------------------- /examples/mcp/e2b_mcp_search.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interactive GitHub Search Assistant with E2B and grep.app 3 | * 4 | * This example demonstrates an interactive chat interface that uses: 5 | * - E2B: Cloud sandbox provider (requires E2B_API_KEY) 6 | * - MCP (Model Context Protocol) with grep.app for GitHub search 7 | * 8 | * Required Environment Variables: 9 | * - OPENAI_API_KEY (required) 10 | * - E2B_API_KEY (required) 11 | * 12 | * Usage: 13 | * npm run example:mcp-e2b 14 | * 15 | * Features: 16 | * - Interactive chat loop for exploring GitHub repositories 17 | * - Real-time tool execution tracking 18 | * - Token usage and cost tracking 19 | * - Persistent conversation history 20 | */ 21 | 22 | import { getTokenCosts } from "@tokenlens/helpers"; 23 | import { ModelMessage, stepCountIs, streamText } from "ai"; 24 | import dotenv from "dotenv"; 25 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; 26 | import path from "node:path"; 27 | import * as readline from "node:readline"; 28 | import { fetchModels } from "tokenlens"; 29 | import { E2BSandboxProvider } from "../../src/sandbox-code-generator/e2b-sandbox-provider.js"; 30 | import { SANDBOX_SYSTEM_PROMPT } from "../../src/sandbox-code-generator/prompts.js"; 31 | import { SandboxManager } from "../../src/sandbox-code-generator/sandbox-manager.js"; 32 | 33 | // Load environment variables 34 | dotenv.config(); 35 | 36 | // Stats interface to track conversation metrics 37 | interface ConversationStats { 38 | totalMessages: number; 39 | apiTokensInput: number; 40 | apiTokensOutput: number; 41 | apiTokensTotal: number; 42 | costUSD: number; 43 | toolCallsThisTurn: number; 44 | totalToolCalls: number; 45 | } 46 | 47 | /** 48 | * Validate required environment variables 49 | */ 50 | function validateEnvironment(): { valid: boolean; missing: string[] } { 51 | const missing: string[] = []; 52 | 53 | if (!process.env.OPENAI_API_KEY) { 54 | missing.push("OPENAI_API_KEY"); 55 | } 56 | 57 | if (!process.env.E2B_API_KEY) { 58 | missing.push("E2B_API_KEY"); 59 | } 60 | 61 | return { 62 | valid: missing.length === 0, 63 | missing, 64 | }; 65 | } 66 | 67 | /** 68 | * Load messages from file 69 | */ 70 | function loadMessages(messagesFilePath: string): ModelMessage[] { 71 | if (existsSync(messagesFilePath)) { 72 | const raw = readFileSync(messagesFilePath, "utf-8"); 73 | return JSON.parse(raw); 74 | } 75 | return []; 76 | } 77 | 78 | /** 79 | * Save messages to file 80 | */ 81 | function saveMessages(messagesFilePath: string, messages: ModelMessage[]) { 82 | const dir = path.dirname(messagesFilePath); 83 | if (!existsSync(dir)) { 84 | mkdirSync(dir, { recursive: true }); 85 | } 86 | writeFileSync(messagesFilePath, JSON.stringify(messages, null, 2)); 87 | } 88 | 89 | async function main() { 90 | console.log("\n🚀 Interactive GitHub Search Assistant\n"); 91 | 92 | // Validate environment variables 93 | const validation = validateEnvironment(); 94 | 95 | if (!validation.valid) { 96 | console.error("❌ Missing required environment variables:"); 97 | validation.missing.forEach((varName) => { 98 | console.error(` - ${varName}`); 99 | }); 100 | console.error( 101 | "\nPlease set these variables in your .env file or environment.\n" 102 | ); 103 | process.exit(1); 104 | } 105 | 106 | console.log("✅ Environment validated\n"); 107 | 108 | // Create E2B sandbox provider 109 | console.log("🔧 Creating E2B sandbox..."); 110 | const sandboxProvider = await E2BSandboxProvider.create({ 111 | timeout: 1800000, // 30 minutes 112 | }); 113 | 114 | // Initialize SandboxManager with E2B provider 115 | console.log("🔧 Setting up sandbox manager..."); 116 | const manager = await SandboxManager.create({ 117 | sandboxProvider, 118 | }); 119 | 120 | // Register MCP tools 121 | console.log("🔧 Registering MCP tools (grep.app)..."); 122 | await manager.register({ 123 | servers: [ 124 | { 125 | name: "grep-app", 126 | url: "https://mcp.grep.app", 127 | }, 128 | ], 129 | }); 130 | 131 | // Get all tools 132 | const tools = manager.getAllTools(); 133 | 134 | const mcpDir = manager.getMcpDir(); 135 | const userCodeDir = manager.getUserCodeDir(); 136 | 137 | // Create session ID and messages file path 138 | const sessionId = `github-search-${new Date() 139 | .toISOString() 140 | .slice(0, 10)}-${Date.now().toString(36)}`; 141 | const storageDir = path.resolve(process.cwd(), ".sandbox-e2b"); 142 | const messagesFilePath = path.resolve( 143 | storageDir, 144 | sessionId, 145 | "conversation.json" 146 | ); 147 | 148 | // Load existing conversation 149 | let messages = loadMessages(messagesFilePath); 150 | 151 | // Fetch OpenAI provider data for token/cost calculations 152 | const openaiProvider = await fetchModels("openai"); 153 | 154 | // Initialize stats 155 | let stats: ConversationStats = { 156 | totalMessages: messages.length, 157 | apiTokensInput: 0, 158 | apiTokensOutput: 0, 159 | apiTokensTotal: 0, 160 | costUSD: 0, 161 | toolCallsThisTurn: 0, 162 | totalToolCalls: 0, 163 | }; 164 | 165 | console.log("\n" + "=".repeat(80)); 166 | console.log("🤖 Interactive GitHub Search Assistant"); 167 | console.log(`Session: ${sessionId}`); 168 | console.log(`Sandbox: E2B | MCP Tools: grep.app`); 169 | console.log("=".repeat(80) + "\n"); 170 | 171 | if (messages.length > 0) { 172 | console.log( 173 | `📝 Loaded ${messages.length} messages from previous session\n` 174 | ); 175 | } 176 | 177 | console.log("💡 Tips:"); 178 | console.log( 179 | " - Ask me to search GitHub repositories for code, patterns, or implementations" 180 | ); 181 | console.log(" - I can read and analyze tool definitions in the sandbox"); 182 | console.log(" - Type 'exit' or 'quit' to end the session\n"); 183 | 184 | // Function to display stats 185 | function displayStats() { 186 | console.log("\n" + "-".repeat(80)); 187 | console.log("📊 Stats:"); 188 | console.log(` Messages: ${stats.totalMessages}`); 189 | console.log(` Tool Calls (this turn): ${stats.toolCallsThisTurn}`); 190 | console.log(` Total Tool Calls: ${stats.totalToolCalls}`); 191 | console.log(` Last API Call:`); 192 | console.log(` Input: ${stats.apiTokensInput} tokens`); 193 | console.log(` Output: ${stats.apiTokensOutput} tokens`); 194 | console.log(` Total: ${stats.apiTokensTotal} tokens`); 195 | console.log(` Cost: $${stats.costUSD.toFixed(6)}`); 196 | console.log("-".repeat(80) + "\n"); 197 | } 198 | 199 | // Create readline interface 200 | const rl = readline.createInterface({ 201 | input: process.stdin, 202 | output: process.stdout, 203 | prompt: "You: ", 204 | }); 205 | 206 | // Display initial stats 207 | displayStats(); 208 | 209 | // Cleanup function 210 | const cleanup = async () => { 211 | rl.close(); 212 | console.log("\n🧹 Cleaning up E2B sandbox..."); 213 | await manager.cleanup(); 214 | console.log("✅ Done!\n"); 215 | }; 216 | 217 | // Main chat loop 218 | rl.prompt(); 219 | 220 | rl.on("line", async (line: string) => { 221 | const userInput = line.trim(); 222 | 223 | if (!userInput) { 224 | rl.prompt(); 225 | return; 226 | } 227 | 228 | // Check for exit command 229 | if ( 230 | userInput.toLowerCase() === "exit" || 231 | userInput.toLowerCase() === "quit" 232 | ) { 233 | await cleanup(); 234 | console.log("👋 Goodbye!"); 235 | process.exit(0); 236 | } 237 | 238 | // Display user message 239 | console.log(`\nYou: ${userInput}`); 240 | 241 | // Add user message to conversation 242 | messages.push({ 243 | role: "user", 244 | content: userInput, 245 | }); 246 | 247 | try { 248 | // Reset tool call counter 249 | stats.toolCallsThisTurn = 0; 250 | 251 | // Stream the response with tool call tracking 252 | const result = streamText({ 253 | model: "openai/gpt-4.1-mini", 254 | tools, 255 | stopWhen: stepCountIs(20), 256 | system: SANDBOX_SYSTEM_PROMPT, 257 | messages, 258 | onStepFinish: (step) => { 259 | const { toolCalls } = step; 260 | if (toolCalls && toolCalls.length > 0) { 261 | stats.toolCallsThisTurn += toolCalls.length; 262 | stats.totalToolCalls += toolCalls.length; 263 | 264 | console.log(`\n🔧 Tool Calls:`); 265 | toolCalls.forEach((call) => { 266 | const toolName = call.toolName; 267 | const args = (call as any).args || {}; 268 | console.log(` - ${toolName}`); 269 | const argsStr = JSON.stringify(args, null, 2); 270 | if (argsStr.length > 200) { 271 | console.log(` ${argsStr.substring(0, 200)}...`); 272 | } else { 273 | console.log(` ${argsStr}`); 274 | } 275 | }); 276 | console.log(""); 277 | } 278 | }, 279 | }); 280 | 281 | // Stream the assistant response in real-time 282 | process.stdout.write("Assistant: "); 283 | let streamedText = ""; 284 | 285 | for await (const textPart of result.textStream) { 286 | streamedText += textPart; 287 | process.stdout.write(textPart); 288 | } 289 | 290 | // Add final newline 291 | console.log("\n"); 292 | 293 | // Get the response and actual token usage 294 | const response = await result.response; 295 | const responseMessages = response.messages; 296 | const actualUsage = await result.usage; 297 | 298 | if (actualUsage && openaiProvider) { 299 | const modelId = "openai/gpt-4.1-mini"; 300 | const costs = getTokenCosts(modelId, actualUsage, openaiProvider); 301 | 302 | const inputTokens = actualUsage.inputTokens || 0; 303 | const outputTokens = actualUsage.outputTokens || 0; 304 | const totalTokens = 305 | actualUsage.totalTokens || inputTokens + outputTokens; 306 | 307 | stats.apiTokensInput = inputTokens; 308 | stats.apiTokensOutput = outputTokens; 309 | stats.apiTokensTotal = totalTokens; 310 | stats.costUSD = costs.totalUSD || 0; 311 | } 312 | 313 | // Append NEW response messages to the conversation 314 | for (const msg of responseMessages) { 315 | messages.push(msg); 316 | } 317 | 318 | // Update message count 319 | stats.totalMessages = messages.length; 320 | 321 | // Save to file 322 | saveMessages(messagesFilePath, messages); 323 | 324 | // Display updated stats 325 | displayStats(); 326 | } catch (error: any) { 327 | console.error(`\n❌ Error: ${error.message}\n`); 328 | } 329 | 330 | // Prompt for next input 331 | rl.prompt(); 332 | }); 333 | 334 | // Handle Ctrl+C to exit 335 | rl.on("SIGINT", async () => { 336 | await cleanup(); 337 | console.log("\n👋 Goodbye!"); 338 | process.exit(0); 339 | }); 340 | } 341 | 342 | main().catch(async (error) => { 343 | console.error("❌ Error:", error); 344 | process.exit(1); 345 | }); 346 | -------------------------------------------------------------------------------- /examples/mcp/local_mcp_search.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interactive Local GitHub Search Assistant 3 | * 4 | * This example uses LocalSandboxProvider with an interactive chat interface: 5 | * - Local file system storage (./.sandbox directory) 6 | * - MCP (Model Context Protocol) with grep.app for GitHub search 7 | * - No cloud sandbox required 8 | * 9 | * Benefits: 10 | * - Inspect generated files directly in .sandbox/ 11 | * - See real-time console output 12 | * - Faster iteration for debugging 13 | * - No cloud credentials needed 14 | * 15 | * Required Environment Variables: 16 | * - OPENAI_API_KEY (required) 17 | * 18 | * Usage: 19 | * npm run example:mcp-local 20 | * 21 | * Features: 22 | * - Interactive chat loop for exploring GitHub repositories 23 | * - Real-time tool execution tracking 24 | * - Token usage and cost tracking 25 | * - Persistent conversation history 26 | * - Local sandbox for file inspection 27 | */ 28 | 29 | import { getTokenCosts } from "@tokenlens/helpers"; 30 | import { ModelMessage, stepCountIs, streamText, tool } from "ai"; 31 | import dotenv from "dotenv"; 32 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; 33 | import path from "node:path"; 34 | import * as readline from "node:readline"; 35 | import { fetchModels } from "tokenlens"; 36 | import { z } from "zod"; 37 | import { LocalSandboxProvider } from "../../src/sandbox-code-generator/local-sandbox-provider.js"; 38 | import { SANDBOX_SYSTEM_PROMPT } from "../../src/sandbox-code-generator/prompts.js"; 39 | import { SandboxManager } from "../../src/sandbox-code-generator/sandbox-manager.js"; 40 | 41 | // Load environment variables 42 | dotenv.config(); 43 | 44 | // Define weather tool 45 | const weatherTool = tool({ 46 | description: "Get the weather in a location", 47 | inputSchema: z.object({ 48 | location: z.string().describe("The location to get the weather for"), 49 | }), 50 | async execute({ location }: { location: string }) { 51 | const temperature = 72 + Math.floor(Math.random() * 21) - 10; 52 | return { 53 | location, 54 | temperature, 55 | units: "°F", 56 | generatedAt: new Date().toISOString(), 57 | }; 58 | }, 59 | }); 60 | 61 | // Stats interface to track conversation metrics 62 | interface ConversationStats { 63 | totalMessages: number; 64 | apiTokensInput: number; 65 | apiTokensOutput: number; 66 | apiTokensTotal: number; 67 | costUSD: number; 68 | toolCallsThisTurn: number; 69 | totalToolCalls: number; 70 | } 71 | 72 | /** 73 | * Validate required environment variables 74 | */ 75 | function validateEnvironment(): { valid: boolean; missing: string[] } { 76 | const missing: string[] = []; 77 | 78 | if (!process.env.OPENAI_API_KEY) { 79 | missing.push("OPENAI_API_KEY"); 80 | } 81 | 82 | return { 83 | valid: missing.length === 0, 84 | missing, 85 | }; 86 | } 87 | 88 | /** 89 | * Load messages from file 90 | */ 91 | function loadMessages(messagesFilePath: string): ModelMessage[] { 92 | if (existsSync(messagesFilePath)) { 93 | const raw = readFileSync(messagesFilePath, "utf-8"); 94 | return JSON.parse(raw); 95 | } 96 | return []; 97 | } 98 | 99 | /** 100 | * Save messages to file 101 | */ 102 | function saveMessages(messagesFilePath: string, messages: ModelMessage[]) { 103 | const dir = path.dirname(messagesFilePath); 104 | if (!existsSync(dir)) { 105 | mkdirSync(dir, { recursive: true }); 106 | } 107 | writeFileSync(messagesFilePath, JSON.stringify(messages, null, 2)); 108 | } 109 | 110 | async function main() { 111 | console.log("\n🚀 Interactive Local GitHub Search Assistant\n"); 112 | 113 | // Validate environment variables 114 | const validation = validateEnvironment(); 115 | 116 | if (!validation.valid) { 117 | console.error("❌ Missing required environment variables:"); 118 | validation.missing.forEach((varName) => { 119 | console.error(` - ${varName}`); 120 | }); 121 | console.error( 122 | "\nPlease set these variables in your .env file or environment.\n" 123 | ); 124 | process.exit(1); 125 | } 126 | 127 | console.log("✅ Environment validated\n"); 128 | 129 | // Create local sandbox provider 130 | console.log("🔧 Creating local sandbox..."); 131 | const sandboxProvider = await LocalSandboxProvider.create({ 132 | sandboxDir: "./.sandbox-local", 133 | cleanOnCreate: false, // Don't clean on create to preserve files between sessions 134 | }); 135 | 136 | console.log(`📁 Sandbox location: ${sandboxProvider.getAbsolutePath()}`); 137 | 138 | // Initialize SandboxManager with local provider 139 | console.log("🔧 Setting up sandbox manager..."); 140 | const manager = await SandboxManager.create({ 141 | sandboxProvider, 142 | }); 143 | 144 | // Register MCP tools and standard tools 145 | console.log("🔧 Registering MCP tools (grep.app) and standard tools..."); 146 | await manager.register({ 147 | servers: [ 148 | { 149 | name: "grep-app", 150 | url: "https://mcp.grep.app", 151 | }, 152 | ], 153 | standardTools: { 154 | weather: weatherTool, 155 | }, 156 | standardToolOptions: { 157 | title: "Local Agent Tools", 158 | }, 159 | }); 160 | 161 | // Get all sandbox tools (exploration and execution) 162 | const sandboxTools = manager.getAllTools(); 163 | 164 | // Combine sandbox tools with the original weather tool 165 | const tools = { 166 | ...sandboxTools, 167 | }; 168 | 169 | const mcpDir = manager.getMcpDir(); 170 | const localToolsDir = manager.getLocalToolsDir(); 171 | const userCodeDir = manager.getUserCodeDir(); 172 | 173 | // Create session ID and messages file path 174 | const sessionId = `github-search-${new Date() 175 | .toISOString() 176 | .slice(0, 10)}-${Date.now().toString(36)}`; 177 | const storageDir = path.resolve(process.cwd(), ".sandbox-local"); 178 | const messagesFilePath = path.resolve( 179 | storageDir, 180 | sessionId, 181 | "conversation.json" 182 | ); 183 | 184 | // Load existing conversation 185 | let messages = loadMessages(messagesFilePath); 186 | 187 | // Fetch OpenAI provider data for token/cost calculations 188 | const openaiProvider = await fetchModels("openai"); 189 | 190 | // Initialize stats 191 | let stats: ConversationStats = { 192 | totalMessages: messages.length, 193 | apiTokensInput: 0, 194 | apiTokensOutput: 0, 195 | apiTokensTotal: 0, 196 | costUSD: 0, 197 | toolCallsThisTurn: 0, 198 | totalToolCalls: 0, 199 | }; 200 | 201 | console.log("\n" + "=".repeat(80)); 202 | console.log("🤖 Interactive Local GitHub Search Assistant"); 203 | console.log(`Session: ${sessionId}`); 204 | console.log( 205 | `Sandbox: Local (.sandbox) | MCP Tools: grep.app | Standard Tools: weather` 206 | ); 207 | console.log("=".repeat(80) + "\n"); 208 | 209 | if (messages.length > 0) { 210 | console.log( 211 | `📝 Loaded ${messages.length} messages from previous session\n` 212 | ); 213 | } 214 | 215 | console.log("💡 Tips:"); 216 | console.log( 217 | " - Ask me to search GitHub repositories for code, patterns, or implementations" 218 | ); 219 | console.log( 220 | " - I can check the weather for any location using the weather tool" 221 | ); 222 | console.log(" - I can read and analyze tool definitions in the sandbox"); 223 | console.log( 224 | ` - Generated files are saved to: ${sandboxProvider.getAbsolutePath()}` 225 | ); 226 | console.log(" - Type 'exit' or 'quit' to end the session\n"); 227 | 228 | // Function to display stats 229 | function displayStats() { 230 | console.log("\n" + "-".repeat(80)); 231 | console.log("📊 Stats:"); 232 | console.log(` Messages: ${stats.totalMessages}`); 233 | console.log(` Tool Calls (this turn): ${stats.toolCallsThisTurn}`); 234 | console.log(` Total Tool Calls: ${stats.totalToolCalls}`); 235 | console.log(` Last API Call:`); 236 | console.log(` Input: ${stats.apiTokensInput} tokens`); 237 | console.log(` Output: ${stats.apiTokensOutput} tokens`); 238 | console.log(` Total: ${stats.apiTokensTotal} tokens`); 239 | console.log(` Cost: $${stats.costUSD.toFixed(6)}`); 240 | console.log("-".repeat(80) + "\n"); 241 | } 242 | 243 | // Create readline interface 244 | const rl = readline.createInterface({ 245 | input: process.stdin, 246 | output: process.stdout, 247 | prompt: "You: ", 248 | }); 249 | 250 | // Display initial stats 251 | displayStats(); 252 | 253 | // Cleanup function 254 | const cleanup = async () => { 255 | rl.close(); 256 | console.log( 257 | `\n💾 Files preserved at: ${sandboxProvider.getAbsolutePath()}` 258 | ); 259 | await sandboxProvider.stop(); 260 | console.log("✅ Done!\n"); 261 | }; 262 | 263 | // Main chat loop 264 | rl.prompt(); 265 | 266 | rl.on("line", async (line: string) => { 267 | const userInput = line.trim(); 268 | 269 | if (!userInput) { 270 | rl.prompt(); 271 | return; 272 | } 273 | 274 | // Check for exit command 275 | if ( 276 | userInput.toLowerCase() === "exit" || 277 | userInput.toLowerCase() === "quit" 278 | ) { 279 | await cleanup(); 280 | console.log("👋 Goodbye!"); 281 | process.exit(0); 282 | } 283 | 284 | // Display user message 285 | console.log(`\nYou: ${userInput}`); 286 | 287 | // Add user message to conversation 288 | messages.push({ 289 | role: "user", 290 | content: userInput, 291 | }); 292 | 293 | try { 294 | // Reset tool call counter 295 | stats.toolCallsThisTurn = 0; 296 | 297 | // Stream the response with tool call tracking 298 | const result = streamText({ 299 | model: "openai/gpt-4.1-mini", 300 | tools, 301 | stopWhen: stepCountIs(20), 302 | system: SANDBOX_SYSTEM_PROMPT, 303 | messages, 304 | onStepFinish: (step) => { 305 | const { toolCalls } = step; 306 | if (toolCalls && toolCalls.length > 0) { 307 | stats.toolCallsThisTurn += toolCalls.length; 308 | stats.totalToolCalls += toolCalls.length; 309 | 310 | console.log(`\n🔧 Tool Calls:`); 311 | toolCalls.forEach((call) => { 312 | const toolName = call.toolName; 313 | const args = (call as any).args || {}; 314 | console.log(` - ${toolName}`); 315 | const argsStr = JSON.stringify(args, null, 2); 316 | if (argsStr.length > 200) { 317 | console.log(` ${argsStr.substring(0, 200)}...`); 318 | } else { 319 | console.log(` ${argsStr}`); 320 | } 321 | }); 322 | console.log(""); 323 | } 324 | }, 325 | }); 326 | 327 | // Stream the assistant response in real-time 328 | process.stdout.write("Assistant: "); 329 | let streamedText = ""; 330 | 331 | for await (const textPart of result.textStream) { 332 | streamedText += textPart; 333 | process.stdout.write(textPart); 334 | } 335 | 336 | // Add final newline 337 | console.log("\n"); 338 | 339 | // Get the response and actual token usage 340 | const response = await result.response; 341 | const responseMessages = response.messages; 342 | const actualUsage = await result.usage; 343 | 344 | if (actualUsage && openaiProvider) { 345 | const modelId = "openai/gpt-4.1-mini"; 346 | const costs = getTokenCosts(modelId, actualUsage, openaiProvider); 347 | 348 | const inputTokens = actualUsage.inputTokens || 0; 349 | const outputTokens = actualUsage.outputTokens || 0; 350 | const totalTokens = 351 | actualUsage.totalTokens || inputTokens + outputTokens; 352 | 353 | stats.apiTokensInput = inputTokens; 354 | stats.apiTokensOutput = outputTokens; 355 | stats.apiTokensTotal = totalTokens; 356 | stats.costUSD = costs.totalUSD || 0; 357 | } 358 | 359 | // Append NEW response messages to the conversation 360 | for (const msg of responseMessages) { 361 | messages.push(msg); 362 | } 363 | 364 | // Update message count 365 | stats.totalMessages = messages.length; 366 | 367 | // Save to file 368 | saveMessages(messagesFilePath, messages); 369 | 370 | // Display updated stats 371 | displayStats(); 372 | } catch (error: any) { 373 | console.error(`\n❌ Error: ${error.message}\n`); 374 | } 375 | 376 | // Prompt for next input 377 | rl.prompt(); 378 | }); 379 | 380 | // Handle Ctrl+C to exit 381 | rl.on("SIGINT", async () => { 382 | await cleanup(); 383 | console.log("\n👋 Goodbye!"); 384 | process.exit(0); 385 | }); 386 | } 387 | 388 | main().catch(async (error) => { 389 | console.error("❌ Error:", error); 390 | process.exit(1); 391 | }); 392 | -------------------------------------------------------------------------------- /src/tool-results-compactor/strategies/index.ts: -------------------------------------------------------------------------------- 1 | import type { ModelMessage } from "ai"; 2 | import { randomUUID } from "node:crypto"; 3 | import type { FileAdapter } from "../../sandbox-code-generator/file-adapter.js"; 4 | 5 | /** 6 | * Metadata wrapper for persisted tool results 7 | */ 8 | interface PersistedToolResult { 9 | metadata: { 10 | toolName: string; 11 | timestamp: string; 12 | toolCallId: string; 13 | sessionId: string; 14 | }; 15 | output: any; 16 | } 17 | 18 | function formatStoragePathForDisplay(storageUri: string, key: string): string { 19 | if (!storageUri) return key; 20 | // For file:// and sandbox:// URIs, show the full path 21 | if (storageUri.startsWith("file://") || storageUri.startsWith("sandbox://")) { 22 | const base = storageUri.replace(/\/$/, ""); 23 | return `${base}/${key}`; 24 | } 25 | // Default formatting uses colon separation 26 | return `${storageUri}:${key}`; 27 | } 28 | 29 | /** 30 | * Determine whether a message has textual content (string or text parts). 31 | * Used to detect conversational boundaries for compaction. 32 | */ 33 | export function messageHasTextContent(message: ModelMessage | any): boolean { 34 | if (!message) return false; 35 | const content: any = (message as any).content; 36 | if (typeof content === "string") return true; 37 | if (Array.isArray(content)) { 38 | return content.some( 39 | (part: any) => 40 | part && part.type === "text" && typeof part.text === "string" 41 | ); 42 | } 43 | return false; 44 | } 45 | 46 | /** 47 | * Controls where the compaction window starts. 48 | * 49 | * - "all": Start at the beginning. Use this to re-compact the full history 50 | * or when earlier tool outputs also need persisting. 51 | * - { type: "keep-first", count: number }: Keep the first N messages intact and start 52 | * compaction afterwards. Useful to preserve initial system/instructions or early context. 53 | * - { type: "keep-last", count: number }: Keep the last N messages intact and compact 54 | * everything before them. Useful to preserve recent context while compacting older messages. 55 | */ 56 | export type Boundary = 57 | | "all" 58 | | { type: "keep-first"; count: number } 59 | | { type: "keep-last"; count: number }; 60 | 61 | /** 62 | * Determine the starting index of the compaction window based on the chosen boundary. 63 | */ 64 | /** 65 | * Determine the starting index of the compaction window based on the chosen boundary. 66 | */ 67 | export function detectWindowStart( 68 | messages: ModelMessage[] | any[], 69 | boundary: Boundary 70 | ): number { 71 | // Start compaction after the first N messages (keep the first N intact) 72 | if ( 73 | typeof boundary === "object" && 74 | boundary !== null && 75 | (boundary as any).type === "keep-first" 76 | ) { 77 | const countRaw = (boundary as any).count; 78 | const n = Number.isFinite(countRaw) 79 | ? Math.max(0, Math.floor(countRaw as number)) 80 | : 0; 81 | const len = Array.isArray(messages) ? messages.length : 0; 82 | // We never compact the final assistant message (loop iterates to length - 1), 83 | // so clamp the start within [0, len - 1] 84 | const upperBound = Math.max(0, len - 1); 85 | return Math.min(n, upperBound); 86 | } 87 | // Start compaction from the beginning (keep the last N messages intact) 88 | if ( 89 | typeof boundary === "object" && 90 | boundary !== null && 91 | (boundary as any).type === "keep-last" 92 | ) { 93 | // Start from 0, will be bounded by endExclusive in detectWindowBounds 94 | return 0; 95 | } 96 | if (boundary === "all") return 0; 97 | const msgs: any[] = Array.isArray(messages) ? messages : []; 98 | let windowStart = 0; 99 | for (let i = msgs.length - 2; i >= 0; i--) { 100 | const m = msgs[i]; 101 | const isBoundary = 102 | m && 103 | (m.role === "assistant" || m.role === "user") && 104 | messageHasTextContent(m); 105 | if (isBoundary) { 106 | windowStart = i + 1; 107 | break; 108 | } 109 | } 110 | return windowStart; 111 | } 112 | 113 | /** 114 | * Determine the [start, end) window for compaction based on the chosen boundary. 115 | * The end index is exclusive. The final assistant message (last item) is never compacted. 116 | */ 117 | export function detectWindowRange( 118 | messages: ModelMessage[] | any[], 119 | boundary: Boundary 120 | ): { start: number; endExclusive: number } { 121 | const len = Array.isArray(messages) ? messages.length : 0; 122 | if (len <= 1) return { start: 0, endExclusive: 0 }; 123 | 124 | // Preserve the first N messages; compact everything after them. 125 | if (typeof boundary === "object" && boundary.type === "keep-first") { 126 | const countRaw = boundary.count; 127 | const n = Number.isFinite(countRaw) 128 | ? Math.max(0, Math.floor(countRaw as number)) 129 | : 0; 130 | // Start after the first N messages, end before the final assistant message 131 | const startIndex = Math.min(n, len - 1); 132 | return { start: startIndex, endExclusive: Math.max(startIndex, len - 1) }; 133 | } 134 | 135 | // Preserve the last N messages; compact everything before them. 136 | if (typeof boundary === "object" && boundary.type === "keep-last") { 137 | const countRaw = boundary.count; 138 | const n = Number.isFinite(countRaw) 139 | ? Math.max(0, Math.floor(countRaw as number)) 140 | : 0; 141 | // Compact from start, end before the last N messages (and before the final assistant message) 142 | const endExclusive = Math.max(0, Math.min(len - 1, len - n)); 143 | return { start: 0, endExclusive }; 144 | } 145 | 146 | return { start: 0, endExclusive: Math.max(0, len - 1) }; 147 | } 148 | 149 | /** 150 | * Options for the write-tool-results-to-file compaction strategy. 151 | */ 152 | export interface WriteToolResultsToFileOptions { 153 | /** Where to start compacting from in the message list. */ 154 | boundary: Boundary; 155 | /** File adapter used to resolve keys and write content. */ 156 | adapter: FileAdapter; 157 | /** Converts tool outputs into strings before writing. Defaults to JSON.stringify. */ 158 | toolResultSerializer: (value: unknown) => string; 159 | /** 160 | * Names of tools that READ from previously written storage (e.g., read/search tools). 161 | * Their results will NOT be re-written; instead a friendly reference to the source is shown. 162 | * Provide custom names for your own reader/search tools. 163 | */ 164 | fileReaderTools?: string[]; 165 | /** 166 | * Optional session ID to organize persisted tool results. 167 | * Files will be organized as: {baseDir}/{sessionId}/tool-results/{toolName}-{seq}.json 168 | */ 169 | sessionId?: string; 170 | } 171 | 172 | export function isToolMessage(msg: any): boolean { 173 | return msg && msg.role === "tool" && Array.isArray(msg.content); 174 | } 175 | 176 | /** 177 | * Compaction strategy that writes tool-result payloads to storage and replaces their in-line 178 | * content with a concise reference to the persisted location. 179 | */ 180 | export async function writeToolResultsToFileStrategy( 181 | messages: ModelMessage[], 182 | options: WriteToolResultsToFileOptions 183 | ): Promise { 184 | const msgs = Array.isArray(messages) ? [...messages] : []; 185 | 186 | const lastMessage = msgs[msgs.length - 1] as any; 187 | const endsWithAssistantText = 188 | lastMessage && 189 | lastMessage.role === "assistant" && 190 | messageHasTextContent(lastMessage); 191 | if (!endsWithAssistantText) return msgs; 192 | 193 | const { start: windowStart, endExclusive } = detectWindowRange( 194 | msgs, 195 | options.boundary 196 | ); 197 | 198 | const sessionId = options.sessionId ?? `session-${randomUUID().slice(0, 8)}`; 199 | 200 | for (let i = windowStart; i < Math.min(endExclusive, msgs.length - 1); i++) { 201 | const msg: any = msgs[i]; 202 | if (!isToolMessage(msg)) continue; 203 | 204 | for (const part of msg.content) { 205 | if (!part || part.type !== "tool-result" || !part.output) continue; 206 | 207 | // Reference-only behavior for tools that read/search storage 208 | // These tools can be re-run to get the same results, so we don't persist their output 209 | const fileReaderSet = new Set(options.fileReaderTools ?? []); 210 | if (part.toolName && fileReaderSet.has(part.toolName)) { 211 | // Find the corresponding tool call from the previous assistant message 212 | let filePath: string | undefined; 213 | 214 | // Look back to find the assistant message with the matching tool call 215 | for (let j = i - 1; j >= 0; j--) { 216 | const assistantMsg: any = msgs[j]; 217 | if ( 218 | assistantMsg?.role === "assistant" && 219 | Array.isArray(assistantMsg.content) 220 | ) { 221 | const toolCall = assistantMsg.content.find( 222 | (item: any) => 223 | item.type === "tool-call" && item.toolCallId === part.toolCallId 224 | ); 225 | if (toolCall?.input) { 226 | // Extract file path from input - try common parameter names 227 | filePath = 228 | toolCall.input.file || 229 | toolCall.input.path || 230 | toolCall.input.query; 231 | break; 232 | } 233 | } 234 | } 235 | 236 | const display = filePath 237 | ? `Read from file: ${filePath}` 238 | : `Read from storage (tool: ${part.toolName})`; 239 | 240 | part.output = { 241 | type: "text", 242 | value: display, 243 | }; 244 | 245 | // No need to register - files are read directly when they exist 246 | continue; 247 | } 248 | 249 | // Extract tool output for persistence 250 | const output: any = part.output; 251 | let outputValue: any; 252 | 253 | if (output && output.type === "json" && output.value !== undefined) { 254 | outputValue = output.value; 255 | } else if ( 256 | output && 257 | output.type === "text" && 258 | typeof output.text === "string" 259 | ) { 260 | outputValue = output.text; 261 | } else { 262 | outputValue = output; 263 | } 264 | 265 | if (!outputValue) continue; 266 | 267 | // Skip if this is already a reference (previously compacted) 268 | if (typeof outputValue === "string") { 269 | if ( 270 | outputValue.startsWith("Written to file:") || 271 | outputValue.startsWith("Read from file:") 272 | ) { 273 | continue; 274 | } 275 | } else if ( 276 | output && 277 | output.type === "text" && 278 | typeof output.value === "string" 279 | ) { 280 | if ( 281 | output.value.startsWith("Written to file:") || 282 | output.value.startsWith("Read from file:") 283 | ) { 284 | continue; 285 | } 286 | } 287 | 288 | // Generate file name based on tool name (one file per tool, overwritten on subsequent calls) 289 | const toolName = part.toolName || "unknown"; 290 | const fileName = `${toolName}.json`; 291 | 292 | // Wrap output with metadata 293 | const persistedResult: PersistedToolResult = { 294 | metadata: { 295 | toolName, 296 | timestamp: new Date().toISOString(), 297 | toolCallId: part.toolCallId || randomUUID(), 298 | sessionId, 299 | }, 300 | output: outputValue, 301 | }; 302 | 303 | const key = options.adapter.resolveKey(fileName); 304 | await options.adapter.write({ 305 | key, 306 | body: JSON.stringify(persistedResult, null, 2), 307 | contentType: "application/json", 308 | }); 309 | 310 | const adapterUri = options.adapter.toString(); 311 | 312 | part.output = { 313 | type: "text", 314 | value: `Written to file: ${formatStoragePathForDisplay( 315 | adapterUri, 316 | key 317 | )}. To read it, use: sandbox_cat({ file: "${key}" })`, 318 | }; 319 | } 320 | } 321 | 322 | return msgs; 323 | } 324 | 325 | /** 326 | * Options for the drop-tool-results compaction strategy. 327 | */ 328 | export interface DropToolResultsOptions { 329 | boundary: Boundary; 330 | } 331 | 332 | /** 333 | * Compaction strategy that drops tool results from the conversation. 334 | */ 335 | export async function dropToolResultsStrategy( 336 | messages: ModelMessage[], 337 | options: DropToolResultsOptions 338 | ): Promise { 339 | const msgs = Array.isArray(messages) ? [...messages] : []; 340 | 341 | const lastMessage = msgs[msgs.length - 1] as any; 342 | const endsWithAssistantText = 343 | lastMessage && 344 | lastMessage.role === "assistant" && 345 | messageHasTextContent(lastMessage); 346 | if (!endsWithAssistantText) return msgs; 347 | 348 | const { start: windowStart, endExclusive } = detectWindowRange( 349 | msgs, 350 | options.boundary 351 | ); 352 | 353 | for (let i = windowStart; i < Math.min(endExclusive, msgs.length - 1); i++) { 354 | const msg: any = msgs[i]; 355 | if (!isToolMessage(msg)) continue; 356 | 357 | for (const part of msg.content) { 358 | if (!part || part.type !== "tool-result" || !part.output) continue; 359 | 360 | // Drop the tool output - remove the output data but keep the tool result structure 361 | part.output = { 362 | type: "text", 363 | value: `Results dropped for tool: ${part.toolName} to preserve context`, 364 | }; 365 | } 366 | } 367 | 368 | return msgs; 369 | } 370 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/sandbox-manager.ts: -------------------------------------------------------------------------------- 1 | // SandboxManager - unified sandbox environment for MCP and standard tools 2 | 3 | import type { Tool } from "ai"; 4 | import { 5 | LocalFileAdapter, 6 | SandboxFileAdapter, 7 | type FileAdapter, 8 | type LocalFileAdapterOptions, 9 | } from "./file-adapter.js"; 10 | import { writeFilesToSandbox, writeUserCodeREADME } from "./file-generator.js"; 11 | import { 12 | LocalSandboxProvider, 13 | type LocalSandboxOptions, 14 | } from "./local-sandbox-provider.js"; 15 | import { fetchToolDefinitions } from "./mcp-client.js"; 16 | import type { SandboxProvider } from "./sandbox-provider.js"; 17 | import { 18 | createExecutionTool, 19 | createExplorationTools, 20 | } from "./sandbox-tools.js"; 21 | import { 22 | writeToolsToSandbox, 23 | type ToolCodeGenerationOptions, 24 | type ToolCodeGenerationResult, 25 | } from "./tool-code-writer.js"; 26 | import type { 27 | MCPServerConfig, 28 | SandboxManagerConfig, 29 | ServerToolsMap, 30 | } from "./types.js"; 31 | 32 | export class SandboxManager { 33 | private readonly sandboxProvider: SandboxProvider; 34 | private readonly workspacePath: string; 35 | private readonly explorationRoot: string; 36 | 37 | private readonly mcpDir: string; 38 | private readonly localToolsDir: string; 39 | private readonly userCodeDir: string; 40 | private readonly compactDir: string; 41 | 42 | private serverToolsMap: ServerToolsMap = {}; 43 | private standardToolsResult?: ToolCodeGenerationResult; 44 | 45 | private constructor(sandboxProvider: SandboxProvider) { 46 | this.sandboxProvider = sandboxProvider; 47 | this.workspacePath = sandboxProvider.getWorkspacePath(); 48 | this.explorationRoot = this.workspacePath; 49 | 50 | // Define the four standard directories 51 | this.mcpDir = `${this.workspacePath}/mcp`; 52 | this.localToolsDir = `${this.workspacePath}/local-tools`; 53 | this.userCodeDir = `${this.workspacePath}/user-code`; 54 | this.compactDir = `${this.workspacePath}/compact`; 55 | } 56 | 57 | /** 58 | * Create a local file adapter for writing to the local filesystem (no sandbox). 59 | * Use this for local development/testing without a sandbox provider. 60 | * By default, files are written to the compact directory. 61 | * 62 | * @param options - Configuration for the local file adapter 63 | * @param options.baseDir - The base directory path 64 | * @param options.prefix - Optional subdirectory prefix (defaults to "compact") 65 | * @param options.sessionId - Optional session ID for organizing files 66 | * @returns A FileAdapter instance for local filesystem operations 67 | */ 68 | static createLocalFileAdapter(options: LocalFileAdapterOptions): FileAdapter { 69 | return new LocalFileAdapter(options); 70 | } 71 | 72 | /** 73 | * Create and initialize a new SandboxManager instance with the base directory structure 74 | */ 75 | static async create( 76 | config: SandboxManagerConfig = {} 77 | ): Promise { 78 | const { sandboxProvider, sandboxOptions = {} } = config; 79 | 80 | let provider: SandboxProvider; 81 | if (sandboxProvider) { 82 | provider = sandboxProvider; 83 | console.log("✓ Using provided sandbox provider"); 84 | } else { 85 | const options = sandboxOptions as LocalSandboxOptions; 86 | provider = await LocalSandboxProvider.create(options); 87 | console.log("✓ Using local sandbox provider (default)"); 88 | } 89 | 90 | const manager = new SandboxManager(provider); 91 | 92 | // Create the four standard directories 93 | console.log("\n🔧 Initializing sandbox directories..."); 94 | await manager.createDirectoryStructure(); 95 | console.log("✓ Sandbox directory structure initialized"); 96 | 97 | return manager; 98 | } 99 | 100 | /** 101 | * Create the standard directory structure (servers, local-tools, user-code, compact) 102 | */ 103 | private async createDirectoryStructure(): Promise { 104 | const dirs = [ 105 | this.mcpDir, 106 | this.localToolsDir, 107 | this.userCodeDir, 108 | this.compactDir, 109 | ]; 110 | 111 | for (const dir of dirs) { 112 | const mkdirResult = await this.sandboxProvider.runCommand({ 113 | cmd: "mkdir", 114 | args: ["-p", dir], 115 | }); 116 | 117 | if (mkdirResult.exitCode !== 0) { 118 | const stderr = await mkdirResult.stderr(); 119 | throw new Error(`Failed to create directory ${dir}: ${stderr}`); 120 | } 121 | } 122 | 123 | // Generate README for user-code directory with instructions 124 | await writeUserCodeREADME(this.sandboxProvider, this.userCodeDir); 125 | } 126 | 127 | /** 128 | * Register MCP servers and/or standard tools for transformation and writing to appropriate directories 129 | */ 130 | async register(options: { 131 | servers?: MCPServerConfig[]; 132 | standardTools?: Record>; 133 | standardToolOptions?: ToolCodeGenerationOptions; 134 | }): Promise { 135 | const { servers = [], standardTools = {}, standardToolOptions } = options; 136 | 137 | if (servers.length === 0 && Object.keys(standardTools).length === 0) { 138 | console.warn("⚠️ No servers or standard tools provided to register()."); 139 | return; 140 | } 141 | 142 | // Process MCP servers 143 | if (servers.length > 0) { 144 | console.log(`\nFetching tools from ${servers.length} MCP server(s)...`); 145 | 146 | for (const server of servers) { 147 | try { 148 | console.log(` Connecting to ${server.name}...`); 149 | const tools = await fetchToolDefinitions(server); 150 | this.serverToolsMap[server.name] = tools; 151 | console.log(` ✓ Found ${tools.length} tools from ${server.name}`); 152 | } catch (error) { 153 | console.error( 154 | ` ✗ Failed to fetch tools from ${server.name}: ${ 155 | error instanceof Error ? error.message : String(error) 156 | }` 157 | ); 158 | } 159 | } 160 | 161 | const totalTools = Object.values(this.serverToolsMap).reduce( 162 | (sum, tools) => sum + tools.length, 163 | 0 164 | ); 165 | 166 | if (totalTools > 0) { 167 | console.log( 168 | `\nGenerating file system with ${totalTools} MCP tool(s)...` 169 | ); 170 | 171 | console.log("\nInstalling MCP dependencies in sandbox..."); 172 | const npmInstallResult = await this.sandboxProvider.runCommand({ 173 | cmd: "npm", 174 | args: [ 175 | "install", 176 | "@modelcontextprotocol/sdk@^1.0.4", 177 | "tsx", 178 | "--no-save", 179 | ], 180 | }); 181 | 182 | if (npmInstallResult.exitCode === 0) { 183 | console.log("✓ Dependencies installed (MCP SDK + tsx)"); 184 | } else { 185 | const stderr = await npmInstallResult.stderr(); 186 | console.warn(`Warning: Failed to install dependencies: ${stderr}`); 187 | } 188 | 189 | await writeFilesToSandbox( 190 | this.sandboxProvider, 191 | this.serverToolsMap, 192 | servers, 193 | this.mcpDir 194 | ); 195 | 196 | console.log(`✓ MCP tool file system generated at ${this.mcpDir}`); 197 | } else { 198 | console.warn( 199 | "⚠️ No MCP tools were fetched from the provided servers." 200 | ); 201 | } 202 | } 203 | 204 | // Process standard tools 205 | if (Object.keys(standardTools).length > 0) { 206 | console.log("\nGenerating source files for standard AI SDK tools..."); 207 | this.standardToolsResult = await writeToolsToSandbox( 208 | this.sandboxProvider, 209 | standardTools, 210 | { 211 | ...standardToolOptions, 212 | outputDir: this.localToolsDir, 213 | } 214 | ); 215 | console.log( 216 | `✓ Generated ${this.standardToolsResult.files.length} file(s) at ${this.standardToolsResult.outputDir}` 217 | ); 218 | } 219 | } 220 | 221 | /** 222 | * Get AI SDK tools for exploring the sandbox file system 223 | */ 224 | getExplorationTools() { 225 | return createExplorationTools(this.sandboxProvider, this.explorationRoot); 226 | } 227 | 228 | /** 229 | * Get AI SDK tools for exploring compacted files in the sandbox. 230 | * These tools default to the workspace root, making it easy to use 231 | * the exact paths provided in compaction messages. 232 | */ 233 | getCompactionTools() { 234 | return createExplorationTools(this.sandboxProvider, this.workspacePath); 235 | } 236 | 237 | /** 238 | * Get AI SDK tool for executing code in the sandbox 239 | */ 240 | getExecutionTool() { 241 | return createExecutionTool(this.sandboxProvider, this.userCodeDir); 242 | } 243 | 244 | /** 245 | * Get both exploration and execution tools 246 | */ 247 | getAllTools() { 248 | return { 249 | ...this.getExplorationTools(), 250 | ...this.getExecutionTool(), 251 | }; 252 | } 253 | 254 | /** 255 | * Get the sandbox provider instance (for advanced use cases) 256 | */ 257 | getSandboxProvider(): SandboxProvider { 258 | return this.sandboxProvider; 259 | } 260 | 261 | /** 262 | * Get the path to the servers directory 263 | */ 264 | getMcpDir(): string { 265 | return this.mcpDir; 266 | } 267 | 268 | /** 269 | * Get the path to the local-tools directory 270 | */ 271 | getLocalToolsDir(): string { 272 | return this.localToolsDir; 273 | } 274 | 275 | /** 276 | * Get the path to the user-code directory 277 | */ 278 | getUserCodeDir(): string { 279 | return this.userCodeDir; 280 | } 281 | 282 | /** 283 | * Get the path to the compact directory (for compacted messages) 284 | */ 285 | getCompactDir(): string { 286 | return this.compactDir; 287 | } 288 | 289 | /** 290 | * Get the workspace path 291 | */ 292 | getWorkspacePath(): string { 293 | return this.workspacePath; 294 | } 295 | 296 | /** 297 | * Get a file adapter for reading/writing files in the sandbox. 298 | * By default, files are written to the compact directory. 299 | * Useful for tool result compaction and other file operations. 300 | * 301 | * @param options - Optional configuration for the file adapter 302 | * @param options.prefix - Optional subdirectory prefix inside sandbox workspace (defaults to "compact") 303 | * @param options.sessionId - Optional session ID for organizing files (creates sessionId/tool-results/ structure) 304 | */ 305 | getFileAdapter(options?: { 306 | prefix?: string; 307 | sessionId?: string; 308 | }): FileAdapter { 309 | return new SandboxFileAdapter({ 310 | sandboxProvider: this.sandboxProvider, 311 | prefix: options?.prefix ?? "compact", 312 | sessionId: options?.sessionId, 313 | }); 314 | } 315 | 316 | /** 317 | * Get the server tools map 318 | */ 319 | getServerToolsMap(): ServerToolsMap { 320 | return this.serverToolsMap; 321 | } 322 | 323 | /** 324 | * Get the result of standard tool generation (if any) 325 | */ 326 | getStandardToolsResult(): ToolCodeGenerationResult | undefined { 327 | return this.standardToolsResult; 328 | } 329 | 330 | /** 331 | * Get information about all discovered tools 332 | */ 333 | getToolsSummary(): { 334 | totalServers: number; 335 | totalMcpTools: number; 336 | servers: Array<{ name: string; toolCount: number; tools: string[] }>; 337 | localTools: string[]; 338 | } { 339 | const servers = Object.entries(this.serverToolsMap).map( 340 | ([name, tools]) => ({ 341 | name, 342 | toolCount: tools.length, 343 | tools: tools.map((t) => t.name), 344 | }) 345 | ); 346 | 347 | const localTools = this.standardToolsResult 348 | ? this.standardToolsResult.tools.map((t) => t.name) 349 | : []; 350 | 351 | return { 352 | totalServers: servers.length, 353 | totalMcpTools: servers.reduce((sum, s) => sum + s.toolCount, 0), 354 | servers, 355 | localTools, 356 | }; 357 | } 358 | 359 | /** 360 | * Display the complete file system tree structure 361 | */ 362 | async displayFileSystemTree(): Promise { 363 | console.log(`\n📂 Sandbox File System Tree at ${this.workspacePath}:`); 364 | console.log("─".repeat(60)); 365 | 366 | const treeCommand = `command -v tree >/dev/null 2>&1 && tree -L 3 ${this.workspacePath} || find ${this.workspacePath} -type f -o -type d | sort | sed 's|${this.workspacePath}||' | sed 's|^/||' | awk '{depth=split($0,a,\"/\"); for(i=1;i0)print a[depth]}'`; 367 | 368 | const treeResult = await this.sandboxProvider.runCommand({ 369 | cmd: "sh", 370 | args: ["-c", treeCommand], 371 | }); 372 | 373 | if (treeResult.exitCode === 0) { 374 | const stdout = await treeResult.stdout(); 375 | console.log(stdout); 376 | } else { 377 | const findResult = await this.sandboxProvider.runCommand({ 378 | cmd: "find", 379 | args: [this.workspacePath, "-type", "f"], 380 | }); 381 | if (findResult.exitCode === 0) { 382 | const stdout = await findResult.stdout(); 383 | console.log(stdout); 384 | } 385 | } 386 | console.log("─".repeat(60)); 387 | } 388 | 389 | /** 390 | * Clean up resources (close sandbox) 391 | */ 392 | async cleanup(): Promise { 393 | console.log("\nCleaning up..."); 394 | try { 395 | await this.sandboxProvider.stop(); 396 | console.log("✓ Sandbox stopped"); 397 | } catch (error) { 398 | console.error( 399 | `Warning: Error stopping sandbox: ${ 400 | error instanceof Error ? error.message : String(error) 401 | }` 402 | ); 403 | } 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /examples/ctx-management/email_management.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interactive Email Assistant with Multi-Environment Support 3 | * 4 | * This example demonstrates context compression with support for multiple environments: 5 | * - Local: Local sandbox with file system storage (no additional setup) 6 | * - E2B: Cloud sandbox provider (requires E2B_API_KEY) 7 | * - Vercel: Cloud sandbox provider (no additional setup currently) 8 | * 9 | * Features: 10 | * - Automatically compacts large tool outputs to storage 11 | * - Provides sandbox exploration tools (sandbox_ls, sandbox_cat, sandbox_grep, sandbox_find) 12 | * - Agent can read compacted files on-demand 13 | * - Tracks token savings and costs 14 | * 15 | * Required Environment Variables: 16 | * - OPENAI_API_KEY (required for all environments) 17 | * - E2B_API_KEY (required only for E2B environment) 18 | * 19 | * Usage: 20 | * npm run example:ctx-local 21 | * 22 | * You'll be prompted to select an environment, and the example will validate 23 | * that all required environment variables are set before starting. 24 | */ 25 | 26 | import { getTokenCosts } from "@tokenlens/helpers"; 27 | import { ModelMessage, stepCountIs, streamText, tool } from "ai"; 28 | import "dotenv/config"; 29 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; 30 | import path from "node:path"; 31 | import * as readline from "node:readline"; 32 | import prompts from "prompts"; 33 | import { fetchModels } from "tokenlens"; 34 | import { z } from "zod"; 35 | import type { FileAdapter } from "../../src/sandbox-code-generator/file-adapter.js"; 36 | import { SandboxManager } from "../../src/sandbox-code-generator/sandbox-manager.js"; 37 | import { compact } from "../../src/tool-results-compactor/index.js"; 38 | 39 | // Environment types 40 | type Environment = "local" | "e2b" | "vercel"; 41 | 42 | // Environment configuration 43 | interface EnvironmentConfig { 44 | type: Environment; 45 | storageBaseDir: string; 46 | sessionId: string; 47 | fileAdapter: FileAdapter; 48 | sandboxManager: SandboxManager; 49 | cleanup?: () => Promise; // Optional cleanup function for sandbox providers 50 | } 51 | 52 | /** 53 | * Validate environment variables for the selected environment 54 | */ 55 | function validateEnvironment(env: Environment): { 56 | valid: boolean; 57 | missing: string[]; 58 | } { 59 | const missing: string[] = []; 60 | 61 | // OpenAI API key is required for all environments 62 | if (!process.env.OPENAI_API_KEY) { 63 | missing.push("OPENAI_API_KEY"); 64 | } 65 | 66 | // E2B specific validation 67 | if (env === "e2b") { 68 | if (!process.env.E2B_API_KEY) { 69 | missing.push("E2B_API_KEY"); 70 | } 71 | } 72 | 73 | // Vercel specific validation (if needed) 74 | if (env === "vercel") { 75 | // Vercel sandbox might use VERCEL_API_TOKEN or work without explicit auth 76 | // Add validation here if needed in the future 77 | } 78 | 79 | return { 80 | valid: missing.length === 0, 81 | missing, 82 | }; 83 | } 84 | 85 | /** 86 | * Prompt user to select environment and validate 87 | */ 88 | async function selectEnvironment(): Promise { 89 | console.log("\n🚀 Interactive Email Assistant - Environment Setup\n"); 90 | 91 | const response = await prompts({ 92 | type: "select", 93 | name: "environment", 94 | message: "Select your environment:", 95 | choices: [ 96 | { title: "Local (file system storage)", value: "local" }, 97 | { title: "E2B (cloud sandbox)", value: "e2b" }, 98 | { title: "Vercel (cloud sandbox)", value: "vercel" }, 99 | ], 100 | initial: 0, 101 | }); 102 | 103 | // Handle Ctrl+C 104 | if (!response.environment) { 105 | console.log("\n👋 Goodbye!"); 106 | process.exit(0); 107 | } 108 | 109 | const environment = response.environment as Environment; 110 | 111 | // Validate environment variables 112 | const validation = validateEnvironment(environment); 113 | 114 | if (!validation.valid) { 115 | console.error("\n❌ Missing required environment variables:"); 116 | validation.missing.forEach((varName) => { 117 | console.error(` - ${varName}`); 118 | }); 119 | console.error( 120 | "\nPlease set these variables in your .env file or environment.\n" 121 | ); 122 | process.exit(1); 123 | } 124 | 125 | console.log(`\n✅ Environment validated: ${environment}\n`); 126 | 127 | // Create session ID 128 | const sessionId = `demo-${new Date() 129 | .toISOString() 130 | .slice(0, 10)}-${Date.now().toString(36)}`; 131 | 132 | // Create sandbox manager and file adapter based on environment 133 | let fileAdapter: FileAdapter; 134 | let sandboxManager: SandboxManager; 135 | let storageBaseDir: string; 136 | let cleanup: (() => Promise) | undefined; 137 | 138 | if (environment === "local") { 139 | // Local file system storage with local sandbox 140 | console.log("Creating local sandbox..."); 141 | storageBaseDir = path.resolve(process.cwd(), ".sandbox-local"); 142 | const { LocalSandboxProvider } = await import( 143 | "../../src/sandbox-code-generator/local-sandbox-provider.js" 144 | ); 145 | const sandboxProvider = await LocalSandboxProvider.create({ 146 | sandboxDir: storageBaseDir, 147 | cleanOnCreate: false, 148 | }); 149 | 150 | sandboxManager = await SandboxManager.create({ 151 | sandboxProvider, 152 | }); 153 | 154 | fileAdapter = sandboxManager.getFileAdapter({ 155 | sessionId, 156 | }); 157 | 158 | cleanup = async () => { 159 | console.log("\n🧹 Cleaning up local sandbox..."); 160 | await sandboxManager.cleanup(); 161 | }; 162 | } else if (environment === "e2b") { 163 | // E2B sandbox storage 164 | console.log("Creating E2B sandbox..."); 165 | const { E2BSandboxProvider } = await import( 166 | "../../src/sandbox-code-generator/e2b-sandbox-provider.js" 167 | ); 168 | const sandboxProvider = await E2BSandboxProvider.create({ 169 | timeout: 1800000, // 30 minutes 170 | }); 171 | 172 | sandboxManager = await SandboxManager.create({ 173 | sandboxProvider, 174 | }); 175 | 176 | storageBaseDir = path.resolve(process.cwd(), `.sandbox-${environment}`); 177 | fileAdapter = sandboxManager.getFileAdapter({ 178 | sessionId, 179 | }); 180 | 181 | cleanup = async () => { 182 | console.log("\n🧹 Cleaning up E2B sandbox..."); 183 | await sandboxManager.cleanup(); 184 | }; 185 | } else if (environment === "vercel") { 186 | // Vercel sandbox storage 187 | console.log("Creating Vercel sandbox..."); 188 | const { VercelSandboxProvider } = await import( 189 | "../../src/sandbox-code-generator/vercel-sandbox-provider.js" 190 | ); 191 | const sandboxProvider = await VercelSandboxProvider.create({ 192 | timeout: 1800000, // 30 minutes 193 | runtime: "node22", 194 | vcpus: 4, 195 | }); 196 | 197 | sandboxManager = await SandboxManager.create({ 198 | sandboxProvider, 199 | }); 200 | 201 | storageBaseDir = path.resolve(process.cwd(), `.sandbox-${environment}`); 202 | fileAdapter = sandboxManager.getFileAdapter({ 203 | sessionId, 204 | }); 205 | 206 | cleanup = async () => { 207 | console.log("\n🧹 Cleaning up Vercel sandbox..."); 208 | await sandboxManager.cleanup(); 209 | }; 210 | } else { 211 | throw new Error(`Unknown environment: ${environment}`); 212 | } 213 | 214 | return { 215 | type: environment, 216 | storageBaseDir, 217 | sessionId, 218 | fileAdapter, 219 | sandboxManager, 220 | cleanup, 221 | }; 222 | } 223 | 224 | /** 225 | * Create tools with the given sandbox manager 226 | */ 227 | function createTools(sandboxManager: SandboxManager) { 228 | return { 229 | // Include compaction tools for reading compacted files 230 | // These tools default to the workspace root, so you can use paths like: 231 | // sandbox_cat({ file: "compact/session-id/tool-results/fetchEmails.json" }) 232 | ...sandboxManager.getCompactionTools(), 233 | 234 | // Email-specific tool 235 | fetchEmails: tool({ 236 | description: "Fetch recent emails for the current user (50 items)", 237 | inputSchema: z 238 | .object({ 239 | limit: z.number().int().min(1).max(200).default(50).optional(), 240 | }) 241 | .optional(), 242 | async execute(input) { 243 | const limit = input?.limit ?? 50; 244 | const fileUrl = new URL("./mock_emails.json", import.meta.url); 245 | const raw = readFileSync(fileUrl, "utf-8"); 246 | const data = JSON.parse(raw); 247 | const emails = Array.isArray(data.emails) 248 | ? data.emails.slice(0, limit) 249 | : []; 250 | return { 251 | meta: { 252 | ...(data.meta ?? {}), 253 | fetchedAt: new Date().toISOString(), 254 | total: emails.length, 255 | }, 256 | emails, 257 | }; 258 | }, 259 | }), 260 | }; 261 | } 262 | 263 | /** 264 | * Estimate token count for messages array 265 | * This is a rough estimation based on character count 266 | * For actual usage, we'll use the AI SDK's usage data 267 | */ 268 | function estimateTokensInMessages(messages: ModelMessage[]): number { 269 | const text = JSON.stringify(messages); 270 | // Rough heuristic: ~4 characters per token 271 | return Math.ceil(text.length / 4); 272 | } 273 | 274 | /** 275 | * Load messages from file 276 | */ 277 | function loadMessages(messagesFilePath: string): ModelMessage[] { 278 | if (existsSync(messagesFilePath)) { 279 | const raw = readFileSync(messagesFilePath, "utf-8"); 280 | return JSON.parse(raw); 281 | } 282 | return []; 283 | } 284 | 285 | /** 286 | * Save messages to file 287 | */ 288 | function saveMessages(messagesFilePath: string, messages: ModelMessage[]) { 289 | const dir = path.dirname(messagesFilePath); 290 | if (!existsSync(dir)) { 291 | mkdirSync(dir, { recursive: true }); 292 | } 293 | writeFileSync(messagesFilePath, JSON.stringify(messages, null, 2)); 294 | } 295 | 296 | // Stats interface to track conversation metrics 297 | interface ConversationStats { 298 | totalMessages: number; 299 | apiTokensInput: number; 300 | apiTokensOutput: number; 301 | apiTokensTotal: number; 302 | costUSD: number; 303 | estimatedTokensBefore: number; 304 | estimatedTokensAfter: number; 305 | tokensSaved: number; 306 | percentSaved: string; 307 | } 308 | 309 | async function main() { 310 | // Parse strategy from command line args (default: write-tool-results-to-file) 311 | const strategyArg = process.argv.find((arg) => arg.startsWith("--strategy=")); 312 | const strategy = 313 | strategyArg?.split("=")[1] || 314 | process.env.COMPACTION_STRATEGY || 315 | "write-tool-results-to-file"; 316 | 317 | if (!["write-tool-results-to-file", "drop-tool-results"].includes(strategy)) { 318 | console.error( 319 | `\n❌ Invalid strategy: ${strategy}\nValid strategies: write-tool-results-to-file, drop-tool-results\n` 320 | ); 321 | process.exit(1); 322 | } 323 | 324 | // Select and validate environment 325 | const envConfig = await selectEnvironment(); 326 | 327 | // Use file adapter and sandbox manager from environment config 328 | const fileAdapter = envConfig.fileAdapter; 329 | const sandboxManager = envConfig.sandboxManager; 330 | 331 | // Create messages file path (stored locally even for sandbox environments) 332 | const messagesFilePath = path.resolve( 333 | envConfig.storageBaseDir, 334 | envConfig.sessionId, 335 | "conversation.json" 336 | ); 337 | 338 | // Create tools (includes exploration tools for reading compacted files) 339 | const tools = createTools(sandboxManager); 340 | 341 | // Fetch OpenAI provider data for token/cost calculations 342 | const openaiProvider = await fetchModels("openai"); 343 | 344 | // Load existing conversation 345 | let messages = loadMessages(messagesFilePath); 346 | 347 | // Initialize stats 348 | let stats: ConversationStats = { 349 | totalMessages: messages.length, 350 | apiTokensInput: 0, 351 | apiTokensOutput: 0, 352 | apiTokensTotal: 0, 353 | costUSD: 0, 354 | estimatedTokensBefore: 0, 355 | estimatedTokensAfter: 0, 356 | tokensSaved: 0, 357 | percentSaved: "0.0", 358 | }; 359 | 360 | // Simple console-based UI 361 | console.log("\n" + "=".repeat(80)); 362 | console.log(`🚀 Interactive Email Assistant`); 363 | console.log( 364 | `Environment: ${envConfig.type.toUpperCase()} | Session: ${ 365 | envConfig.sessionId 366 | }` 367 | ); 368 | console.log(`Strategy: ${strategy}`); 369 | console.log(`Storage: ${fileAdapter.toString()}`); 370 | console.log("=".repeat(80) + "\n"); 371 | 372 | if (messages.length > 0) { 373 | console.log( 374 | `📝 Loaded ${messages.length} messages from previous session\n` 375 | ); 376 | } 377 | 378 | // Function to display stats 379 | function displayStats() { 380 | console.log("\n" + "-".repeat(80)); 381 | console.log("📊 Stats:"); 382 | console.log(` Messages: ${stats.totalMessages}`); 383 | console.log(` Last API Call:`); 384 | console.log(` Input: ${stats.apiTokensInput} tokens`); 385 | console.log(` Output: ${stats.apiTokensOutput} tokens`); 386 | console.log(` Total: ${stats.apiTokensTotal} tokens`); 387 | console.log(` Cost: $${stats.costUSD.toFixed(6)}`); 388 | console.log(` Compaction:`); 389 | console.log(` Before: ${stats.estimatedTokensBefore} tokens`); 390 | console.log(` After: ${stats.estimatedTokensAfter} tokens`); 391 | console.log( 392 | ` Saved: ${stats.tokensSaved} tokens (${stats.percentSaved}% reduction)` 393 | ); 394 | console.log("-".repeat(80) + "\n"); 395 | } 396 | 397 | // Create readline interface 398 | const rl = readline.createInterface({ 399 | input: process.stdin, 400 | output: process.stdout, 401 | prompt: "You: ", 402 | }); 403 | 404 | // Display initial stats 405 | displayStats(); 406 | 407 | // Main chat loop 408 | rl.prompt(); 409 | 410 | rl.on("line", async (line: string) => { 411 | const userInput = line.trim(); 412 | 413 | if (!userInput) { 414 | rl.prompt(); 415 | return; 416 | } 417 | 418 | // Check for exit command 419 | if ( 420 | userInput.toLowerCase() === "exit" || 421 | userInput.toLowerCase() === "quit" 422 | ) { 423 | rl.close(); 424 | if (envConfig.cleanup) { 425 | await envConfig.cleanup(); 426 | } 427 | console.log("\n👋 Goodbye!"); 428 | process.exit(0); 429 | } 430 | 431 | // Display user message 432 | console.log(`\nYou: ${userInput}`); 433 | 434 | // Add user message to conversation 435 | messages.push({ 436 | role: "user", 437 | content: userInput, 438 | }); 439 | 440 | try { 441 | // Stream the response with tool call tracking 442 | const result = streamText({ 443 | model: "openai/gpt-4.1-mini", 444 | tools, 445 | stopWhen: stepCountIs(4), 446 | system: 447 | "You are a helpful assistant that can help with emails. IMPORTANT: When you use tools and receive data, NEVER include the raw tool output, JSON, or technical data in your response to the user. Parse the data internally and respond naturally with only the relevant information the user asked for.", 448 | messages, 449 | }); 450 | 451 | // Stream the assistant response in real-time 452 | process.stdout.write("Assistant: "); 453 | let streamedText = ""; 454 | 455 | // Use fullStream and only show text, filtering out all tool-related events 456 | for await (const part of result.fullStream) { 457 | switch (part.type) { 458 | case "text-delta": 459 | // Only output actual assistant text 460 | streamedText += part.text; 461 | process.stdout.write(part.text); 462 | break; 463 | case "tool-call": 464 | case "tool-result": 465 | // Skip all tool-related events - don't print anything 466 | break; 467 | // Ignore all other event types silently 468 | default: 469 | break; 470 | } 471 | } 472 | 473 | // Add final newline 474 | console.log("\n"); 475 | 476 | // Get the response and actual token usage 477 | const response = await result.response; 478 | const responseMessages = response.messages; 479 | const actualUsage = await result.usage; 480 | 481 | if (actualUsage && openaiProvider) { 482 | const modelId = "openai/gpt-4.1-mini"; 483 | const costs = getTokenCosts(modelId, actualUsage, openaiProvider); 484 | 485 | const inputTokens = actualUsage.inputTokens || 0; 486 | const outputTokens = actualUsage.outputTokens || 0; 487 | const totalTokens = 488 | actualUsage.totalTokens || inputTokens + outputTokens; 489 | 490 | stats.apiTokensInput = inputTokens; 491 | stats.apiTokensOutput = outputTokens; 492 | stats.apiTokensTotal = totalTokens; 493 | stats.costUSD = costs.totalUSD || 0; 494 | } 495 | 496 | // Append NEW response messages to the conversation 497 | for (const msg of responseMessages) { 498 | messages.push(msg); 499 | } 500 | 501 | // Update message count 502 | stats.totalMessages = messages.length; 503 | 504 | // Show stats before compaction 505 | const tokensBefore = estimateTokensInMessages(messages); 506 | stats.estimatedTokensBefore = tokensBefore; 507 | 508 | // Compact the ENTIRE conversation 509 | const compacted = await compact(messages, { 510 | strategy: strategy as 511 | | "write-tool-results-to-file" 512 | | "drop-tool-results", 513 | storage: fileAdapter, 514 | boundary: "all", 515 | sessionId: envConfig.sessionId, 516 | }); 517 | 518 | const tokensAfter = estimateTokensInMessages(compacted); 519 | const tokensSaved = tokensBefore - tokensAfter; 520 | const percentSaved = 521 | tokensBefore > 0 522 | ? ((tokensSaved / tokensBefore) * 100).toFixed(1) 523 | : "0.0"; 524 | 525 | stats.estimatedTokensAfter = tokensAfter; 526 | stats.tokensSaved = tokensSaved; 527 | stats.percentSaved = percentSaved; 528 | 529 | // Update messages with compacted version 530 | messages = compacted; 531 | 532 | // Save to file 533 | saveMessages(messagesFilePath, messages); 534 | 535 | // Display updated stats 536 | displayStats(); 537 | } catch (error: any) { 538 | console.error(`\n❌ Error: ${error.message}\n`); 539 | } 540 | 541 | // Prompt for next input 542 | rl.prompt(); 543 | }); 544 | 545 | // Handle Ctrl+C to exit 546 | rl.on("SIGINT", async () => { 547 | rl.close(); 548 | if (envConfig.cleanup) { 549 | await envConfig.cleanup(); 550 | } 551 | console.log("\n\n👋 Goodbye!"); 552 | process.exit(0); 553 | }); 554 | } 555 | 556 | main().catch(async (err) => { 557 | console.error(err); 558 | process.exit(1); 559 | }); 560 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ctx-zip 2 | 3 | Keep your AI agent context small and cheap by managing tool bloat and large outputs. ctx-zip provides two complementary techniques: **Tool Discovery** (transform MCP servers and tools into explorable code) and **Output Compaction** (persist large results to storage with smart retrieval). 4 | 5 | Works with the AI SDK for agents and loop control. See: [AI SDK – Loop Control: Context Management](https://ai-sdk.dev/docs/agents/loop-control#context-management). 6 | 7 | ## Installation 8 | 9 | ```bash 10 | npm i ctx-zip 11 | # or 12 | pnpm add ctx-zip 13 | ``` 14 | 15 | --- 16 | 17 | ## What It Does 18 | 19 | ctx-zip tackles two major context problems: 20 | 21 | ### 1. **Tool Discovery & Code Generation** 22 | Transforms MCP servers and AI SDK tools into inspectable TypeScript code in a sandbox. Instead of loading hundreds of tool schemas upfront, agents explore tools on-demand using filesystem operations. 23 | 24 | **The Problem:** 25 | 26 | Context Rot 27 | - Tool schemas consume thousands of tokens before your prompt 28 | - Every tool output is pulled into the model context 29 | - Every tool definition passes through model context 30 | - Agents can't inspect implementations to understand behavior 31 | 32 | **The Solution:** 33 | - Tools are transformed to importable code that live in the sandbox filesystem (not in context) 34 | - Progressive Exploration: Agents explore the file system on-demand with `sandbox_ls`, `sandbox_cat`, `sandbox_grep` 35 | - Agents write and execute code that combines multiple tools 36 | - **Result**: Higher reliability, fast and ~80%+ token reduction for multi-tool workflows 37 | 38 | ### 2. **Output Compaction with Smart Retrieval** 39 | Automatically persists large tool outputs to the sandbox filesystem and replaces them with concise references. Agents can retrieve content on-demand using the same sandbox exploration tools. 40 | 41 | **The Problem:** 42 | 43 | Context Rot 44 | - Large tool outputs (search results, file contents, API responses) bloat context 45 | - Conversation history accumulates thousands of unused tokens 46 | - Context windows exhaust, costs increase, performance degrades 47 | 48 | **The Solution:** 49 | - Tool outputs are automatically written to the sandbox filesystem 50 | - Replaced with short references in conversation 51 | - Agents retrieve data on-demand with sandbox exploration tools (`sandbox_ls`, `sandbox_cat`, `sandbox_grep`, `sandbox_find`) 52 | - **Result**: ~60-90% token reduction for tool-heavy conversations 53 | 54 | --- 55 | 56 | ## How It Works 57 | 58 | ### Technique 1: Tool Discovery & Code Generation 59 | 60 | Transform tools into explorable code that agents can inspect and execute: 61 | 62 | ```typescript 63 | import { generateText, tool } from "ai"; 64 | import { z } from "zod"; 65 | import { SandboxManager, E2BSandboxProvider } from "ctx-zip"; 66 | 67 | // Step 1: Create a sandbox 68 | const sandboxProvider = await E2BSandboxProvider.create(); 69 | const manager = await SandboxManager.create({ sandboxProvider }); 70 | 71 | // Step 2: Register MCP servers and/or AI SDK tools 72 | await manager.register({ 73 | servers: [ 74 | { name: "grep-app", url: "https://mcp.grep.app" }, 75 | { 76 | name: "linear", 77 | url: "https://mcp.linear.app/mcp", 78 | headers: { Authorization: `Bearer ${process.env.LINEAR_TOKEN}` } 79 | }, 80 | ], 81 | standardTools: { 82 | weather: tool({ 83 | description: "Get the weather in a location", 84 | inputSchema: z.object({ 85 | location: z.string(), 86 | }), 87 | async execute({ location }) { 88 | const temp = 72 + Math.floor(Math.random() * 21) - 10; 89 | return { location, temperature: temp, units: "°F" }; 90 | }, 91 | }), 92 | }, 93 | }); 94 | 95 | // Step 3: Get exploration and execution tools 96 | const tools = manager.getAllTools(); 97 | // Available: sandbox_ls, sandbox_cat, sandbox_grep, sandbox_find, sandbox_exec 98 | 99 | // Step 4: Agent explores and uses tools 100 | const result = await generateText({ 101 | model: "openai/gpt-4.1-mini", 102 | tools, 103 | prompt: "Search the codebase, check the weather, and create a Linear issue", 104 | }); 105 | 106 | console.log(result.text); 107 | 108 | // Cleanup 109 | await manager.cleanup(); 110 | ``` 111 | 112 | **What Gets Generated:** 113 | 114 | The `register()` call creates a directory structure in the sandbox: 115 | 116 | ``` 117 | /workspace/ 118 | ├── mcp/ # MCP tool implementations 119 | │ ├── grep-app/ 120 | │ │ ├── search.ts 121 | │ │ ├── index.ts 122 | │ │ └── _types.ts 123 | │ ├── linear/ 124 | │ │ ├── createIssue.ts 125 | │ │ ├── searchIssues.ts 126 | │ │ └── index.ts 127 | │ └── _client.ts # MCP routing client 128 | ├── local-tools/ # AI SDK tool implementations 129 | │ ├── weather.ts 130 | │ └── index.ts 131 | ├── user-code/ # Agent execution workspace 132 | └── compact/ # Tool output storage (for compaction) 133 | ``` 134 | 135 | **Exploration Tools:** 136 | 137 | Once tools are registered, agents can explore them: 138 | 139 | - `sandbox_ls(path)` - List directory contents 140 | - `sandbox_cat(path)` - Read file contents 141 | - `sandbox_grep(pattern, path)` - Search in files 142 | - `sandbox_find(pattern, path)` - Find files by name 143 | - `sandbox_exec(code)` - Execute TypeScript code 144 | 145 | **Example Agent Workflow:** 146 | 147 | ```typescript 148 | // Agent explores available tools 149 | await sandbox_ls("/workspace/mcp") 150 | // → ["grep-app/", "linear/", "_client.ts"] 151 | 152 | await sandbox_ls("/workspace/local-tools") 153 | // → ["weather.ts", "index.ts"] 154 | 155 | // Agent inspects a tool 156 | await sandbox_cat("/workspace/mcp/grep-app/search.ts") 157 | // → Full TypeScript source with types and documentation 158 | 159 | // Agent writes code to use multiple tools together 160 | await sandbox_exec(` 161 | import { search } from './mcp/grep-app/index.ts'; 162 | import { createIssue } from './mcp/linear/index.ts'; 163 | import { weather } from './local-tools/index.ts'; 164 | 165 | const results = await search({ query: 'authentication bug' }); 166 | const topResult = results[0]; 167 | const weatherData = await weather({ location: 'San Francisco' }); 168 | 169 | await createIssue({ 170 | title: 'Fix auth bug from codebase search', 171 | description: \`Found issue: \${topResult.content}\nWeather: \${weatherData.temperature}\`, 172 | }); 173 | 174 | return { created: true, result: topResult.file }; 175 | `); 176 | ``` 177 | 178 | **API Reference:** 179 | 180 | ```typescript 181 | // Create manager 182 | const manager = await SandboxManager.create({ 183 | sandboxProvider?: SandboxProvider, // E2B, Vercel, or Local 184 | sandboxOptions?: LocalSandboxOptions, // If no provider 185 | }); 186 | 187 | // Register tools 188 | await manager.register({ 189 | servers?: MCPServerConfig[], // MCP servers to connect 190 | standardTools?: Record, // AI SDK tools 191 | standardToolOptions?: { 192 | title?: string, 193 | outputDir?: string, 194 | }, 195 | }); 196 | 197 | // Get tools 198 | manager.getAllTools() // Exploration + execution tools 199 | manager.getExplorationTools() // ls, cat, grep, find (for MCP tools, defaults to mcp dir) 200 | manager.getCompactionTools() // ls, cat, grep, find (for compacted files, defaults to workspace root) 201 | manager.getExecutionTool() // Only exec 202 | 203 | // Get paths 204 | manager.getMcpDir() // /workspace/mcp 205 | manager.getLocalToolsDir() // /workspace/local-tools 206 | manager.getUserCodeDir() // /workspace/user-code 207 | manager.getCompactDir() // /workspace/compact 208 | manager.getWorkspacePath() // /workspace 209 | 210 | // Cleanup 211 | await manager.cleanup() 212 | ``` 213 | 214 | --- 215 | 216 | ### Technique 2: Output Compaction with Smart Retrieval 217 | 218 | Automatically reduce context size by managing large tool outputs. Two strategies available: **write-to-file** (persist to storage with on-demand retrieval) or **drop-results** (remove outputs entirely). 219 | 220 | ```typescript 221 | import { generateText, tool } from "ai"; 222 | import { z } from "zod"; 223 | import { compact, SandboxManager } from "ctx-zip"; 224 | 225 | // Create sandbox manager 226 | const manager = await SandboxManager.create(); 227 | const fileAdapter = manager.getFileAdapter({ sessionId: "my-session" }); 228 | 229 | const result = await generateText({ 230 | model: "openai/gpt-4.1-mini", 231 | tools: { 232 | // Compaction tools for reading compacted files using exact paths 233 | ...manager.getCompactionTools(), 234 | 235 | // Your data-generating tools 236 | fetchEmails: tool({ 237 | description: "Fetch emails from inbox", 238 | inputSchema: z.object({ limit: z.number() }), 239 | async execute({ limit }) { 240 | const emails = await getEmails(limit); 241 | return { emails }; // This will be compacted 242 | }, 243 | }), 244 | }, 245 | prompt: "Check my latest emails and find any about 'budget'", 246 | prepareStep: async ({ messages }) => { 247 | // Compact outputs after each turn (default: write-tool-results-to-file) 248 | const compacted = await compact(messages, { 249 | strategy: "write-tool-results-to-file", // or "drop-tool-results" 250 | storage: fileAdapter, // Required for write-tool-results-to-file 251 | boundary: "all", 252 | sessionId: "my-session", 253 | }); 254 | 255 | return { messages: compacted }; 256 | }, 257 | }); 258 | 259 | await manager.cleanup(); 260 | ``` 261 | 262 | **Compaction Strategies:** 263 | 264 | ctx-zip provides two strategies for managing tool outputs: 265 | 266 | #### Strategy 1: `write-tool-results-to-file` (Default) 267 | 268 | Persists tool outputs to storage and replaces them with references. Agents can retrieve data on-demand. 269 | 270 | **How it works:** 271 | 272 | 1. **Agent calls a tool** (e.g., `fetchEmails(50)`) 273 | 2. **Large output returned** (50 emails = 10,000 tokens) 274 | 3. **`compact()` runs in `prepareStep`:** 275 | - Detects large tool output 276 | - Writes to storage: `/compact/my-session/tool-results/fetchEmails.json` 277 | - Replaces output with reference: 278 | ``` 279 | Written to file: file:///path/compact/my-session/tool-results/fetchEmails.json 280 | Key: compact/my-session/tool-results/fetchEmails.json 281 | Use the read/search tools to inspect its contents. 282 | ``` 283 | 4. **Agent can retrieve data:** 284 | - `sandbox_ls(path)` - List directory contents 285 | - `sandbox_cat(file)` - Read entire file 286 | - `sandbox_grep(pattern, path)` - Search within files 287 | - `sandbox_find(pattern, path)` - Find files by name pattern 288 | 289 | **When to use:** When you need agents to access historical tool outputs later in the conversation. 290 | 291 | #### Strategy 2: `drop-tool-results` 292 | 293 | Removes tool outputs entirely from the conversation, replacing them with a simple message indicating the output was dropped. 294 | 295 | **How it works:** 296 | 297 | 1. **Agent calls a tool** (e.g., `fetchEmails(50)`) 298 | 2. **Large output returned** (50 emails = 10,000 tokens) 299 | 3. **`compact()` runs in `prepareStep`:** 300 | - Detects tool output 301 | - Replaces output with: `"Results dropped for tool: fetchEmails to preserve context"` 302 | - No storage required - outputs are permanently removed 303 | 304 | **When to use:** When tool outputs are only needed for immediate processing and don't need to be referenced later. Maximum token savings, simplest setup. 305 | 306 | **Storage Location** (write-tool-results-to-file only): 307 | 308 | When using `SandboxManager` with `write-tool-results-to-file` strategy: 309 | 310 | ``` 311 | /workspace/ 312 | └── compact/ 313 | └── {sessionId}/ 314 | └── tool-results/ 315 | ├── fetchEmails.json 316 | ├── searchGitHub.json 317 | └── getWeather.json 318 | ``` 319 | 320 | Each tool overwrites its own file on subsequent calls (one file per tool type). 321 | 322 | **Note:** The `drop-tool-results` strategy doesn't use storage - outputs are removed from the conversation entirely. 323 | 324 | **Boundary Strategies:** 325 | 326 | Control which messages get compacted: 327 | 328 | ```typescript 329 | 330 | // 1. All messages - re-compact entire conversation 331 | boundary: "all" 332 | 333 | // 2. Keep first N - preserve system prompt, compact rest 334 | boundary: { type: "keep-first", count: 5 } 335 | 336 | // 3. Keep last N - preserve recent context, compact older 337 | boundary: { type: "keep-last", count: 20 } 338 | ``` 339 | 340 | **Sandbox Tools for Retrieval** (write-tool-results-to-file only): 341 | 342 | When using `write-tool-results-to-file`, use compaction-specific tools that default to the workspace root: 343 | 344 | ```typescript 345 | const manager = await SandboxManager.create(); 346 | const tools = manager.getCompactionTools(); 347 | // Available: sandbox_ls, sandbox_cat, sandbox_grep, sandbox_find 348 | // These default to workspace root for easy use with compaction paths 349 | 350 | // The compaction message tells you exactly how to read the file: 351 | // "Written to file: sandbox://... To read it, use: sandbox_cat({ file: "compact/..." })" 352 | // Just copy the path from the message! 353 | ``` 354 | 355 | **Note:** The `drop-tool-results` strategy doesn't require retrieval tools since outputs are permanently removed. 356 | 357 | **Example: Drop Strategy (No Storage Required)** 358 | 359 | ```typescript 360 | import { generateText, tool } from "ai"; 361 | import { z } from "zod"; 362 | import { compact } from "ctx-zip"; 363 | 364 | const result = await generateText({ 365 | model: "openai/gpt-4.1-mini", 366 | tools: { 367 | fetchEmails: tool({ 368 | description: "Fetch emails from inbox", 369 | inputSchema: z.object({ limit: z.number() }), 370 | async execute({ limit }) { 371 | const emails = await getEmails(limit); 372 | return { emails }; // This will be dropped 373 | }, 374 | }), 375 | }, 376 | prompt: "Summarize my latest emails", 377 | prepareStep: async ({ messages }) => { 378 | // Drop tool outputs - no storage needed 379 | const compacted = await compact(messages, { 380 | strategy: "drop-tool-results", 381 | boundary: "all", 382 | }); 383 | 384 | return { messages: compacted }; 385 | }, 386 | }); 387 | ``` 388 | 389 | **API Reference:** 390 | 391 | ```typescript 392 | // Compact messages 393 | const compacted = await compact(messages, { 394 | strategy?: "write-tool-results-to-file" | "drop-tool-results", // Default: write-tool-results-to-file 395 | storage?: FileAdapter | string, // Required for write-tool-results-to-file, ignored for drop-tool-results 396 | boundary?: Boundary, // Which messages to compact 397 | sessionId?: string, // Organize by session (write-tool-results-to-file only) 398 | fileReaderTools?: string[], // Tools that read (not persisted, write-tool-results-to-file only) 399 | }); 400 | ``` 401 | 402 | --- 403 | 404 | ## Combining Both Techniques 405 | 406 | Use tool discovery and compaction together for maximum efficiency: 407 | 408 | ```typescript 409 | import { generateText } from "ai"; 410 | import { 411 | SandboxManager, 412 | compact, 413 | E2BSandboxProvider, 414 | } from "ctx-zip"; 415 | 416 | // Step 1: Setup sandbox with tools 417 | const sandboxProvider = await E2BSandboxProvider.create(); 418 | const manager = await SandboxManager.create({ sandboxProvider }); 419 | 420 | await manager.register({ 421 | servers: [ 422 | { name: "grep-app", url: "https://mcp.grep.app" }, 423 | ], 424 | }); 425 | 426 | // Step 2: Get file adapter for compaction 427 | const fileAdapter = manager.getFileAdapter({ 428 | sessionId: "combined-session", 429 | }); 430 | 431 | // Step 3: Get all sandbox tools (exploration + execution) 432 | // These same tools are used for both tool discovery AND accessing compacted outputs 433 | const tools = manager.getAllTools(); 434 | 435 | // Step 4: Use in agent loop with compaction 436 | const result = await generateText({ 437 | model: "openai/gpt-4.1-mini", 438 | tools, 439 | prompt: "Search for authentication bugs in the codebase and summarize", 440 | prepareStep: async ({ messages }) => { 441 | const compacted = await compact(messages, { 442 | strategy: "write-tool-results-to-file", // or "drop-tool-results" 443 | storage: fileAdapter, // Required for write-tool-results-to-file 444 | boundary: "all", 445 | sessionId: "combined-session", 446 | }); 447 | return { messages: compacted }; 448 | }, 449 | }); 450 | 451 | console.log(result.text); 452 | await manager.cleanup(); 453 | ``` 454 | 455 | **Benefits:** 456 | - MCP tools explored on-demand (no upfront schema loading) 457 | - Large search results compacted to sandbox storage 458 | - Same exploration tools work for both tool discovery and compacted output retrieval 459 | - Maximum token efficiency and simplified API 460 | 461 | --- 462 | 463 | ## Sandbox Providers 464 | 465 | ctx-zip supports three sandbox environments: 466 | 467 | ### Local Sandbox (Default) 468 | 469 | ```typescript 470 | import { SandboxManager, LocalSandboxProvider } from "ctx-zip"; 471 | 472 | // Option 1: Let SandboxManager create default local sandbox 473 | const manager = await SandboxManager.create(); 474 | 475 | // Option 2: Explicit local provider 476 | const provider = await LocalSandboxProvider.create({ 477 | sandboxDir: "./.sandbox", 478 | cleanOnCreate: false, 479 | }); 480 | const manager = await SandboxManager.create({ sandboxProvider: provider }); 481 | ``` 482 | 483 | ### E2B Sandbox 484 | 485 | ```typescript 486 | import { SandboxManager, E2BSandboxProvider } from "ctx-zip"; 487 | 488 | const provider = await E2BSandboxProvider.create({ 489 | apiKey: process.env.E2B_API_KEY, 490 | timeout: 1800000, // 30 minutes 491 | }); 492 | 493 | const manager = await SandboxManager.create({ sandboxProvider: provider }); 494 | ``` 495 | 496 | ### Vercel Sandbox 497 | 498 | ```typescript 499 | import { SandboxManager, VercelSandboxProvider } from "ctx-zip"; 500 | 501 | const provider = await VercelSandboxProvider.create({ 502 | timeout: 1800000, 503 | runtime: "node22", 504 | vcpus: 4, 505 | }); 506 | 507 | const manager = await SandboxManager.create({ sandboxProvider: provider }); 508 | ``` 509 | 510 | --- 511 | 512 | ## Examples 513 | 514 | See the `examples/` directory: 515 | 516 | - **`examples/mcp/`** - MCP server integration with grep.app 517 | - `local_mcp_search.ts` - Local sandbox 518 | - `e2b_mcp_search.ts` - E2B sandbox 519 | - `vercel_mcp_search.ts` - Vercel sandbox 520 | 521 | - **`examples/tools/`** - AI SDK tool transformation 522 | - `weather_tool_sandbox.ts` - Transform and explore standard tools 523 | 524 | - **`examples/ctx-management/`** - Full-featured compaction demo 525 | - `email_management.ts` - Interactive email assistant with multi-environment support 526 | 527 | --- 528 | 529 | ## API Overview 530 | 531 | ### SandboxManager 532 | 533 | ```typescript 534 | class SandboxManager { 535 | // Create 536 | static async create(config?: { 537 | sandboxProvider?: SandboxProvider, 538 | sandboxOptions?: LocalSandboxOptions, 539 | }): Promise 540 | 541 | // Register tools 542 | async register(options: { 543 | servers?: MCPServerConfig[], 544 | standardTools?: Record, 545 | standardToolOptions?: ToolCodeGenerationOptions, 546 | }): Promise 547 | 548 | // Get tools 549 | getAllTools(): Record 550 | getExplorationTools(): Record 551 | getExecutionTool(): Record 552 | 553 | // Get paths 554 | getMcpDir(): string 555 | getLocalToolsDir(): string 556 | getUserCodeDir(): string 557 | getCompactDir(): string 558 | getWorkspacePath(): string 559 | 560 | // File adapter for compaction 561 | getFileAdapter(options?: { 562 | prefix?: string, 563 | sessionId?: string, 564 | }): FileAdapter 565 | 566 | // Static helper 567 | static createLocalFileAdapter(options: LocalFileAdapterOptions): FileAdapter 568 | 569 | // Cleanup 570 | async cleanup(): Promise 571 | } 572 | ``` 573 | 574 | ### Compaction 575 | 576 | ```typescript 577 | function compact( 578 | messages: ModelMessage[], 579 | options: CompactOptions 580 | ): Promise 581 | 582 | interface CompactOptions { 583 | strategy?: "write-tool-results-to-file" | "drop-tool-results", 584 | storage?: FileAdapter | string, // Required for write-tool-results-to-file 585 | boundary?: Boundary, 586 | sessionId?: string, // write-tool-results-to-file only 587 | fileReaderTools?: string[], // write-tool-results-to-file only 588 | } 589 | 590 | type Boundary = 591 | | "all" 592 | | { type: "keep-first"; count: number } 593 | | { type: "keep-last"; count: number } 594 | ``` 595 | 596 | --- 597 | 598 | ## TypeScript Support 599 | 600 | Full TypeScript support with exported types: 601 | 602 | ```typescript 603 | import type { 604 | SandboxProvider, 605 | FileAdapter, 606 | CompactOptions, 607 | Boundary, 608 | E2BSandboxOptions, 609 | LocalSandboxOptions, 610 | VercelSandboxOptions, 611 | } from "ctx-zip"; 612 | ``` 613 | 614 | --- 615 | 616 | ## License 617 | 618 | MIT 619 | 620 | --- 621 | 622 | ## Contributing 623 | 624 | Contributions welcome! Please open an issue or PR. 625 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/sandbox-tools.ts: -------------------------------------------------------------------------------- 1 | // AI SDK tools for exploring and executing code in the sandbox 2 | 3 | import { tool } from "ai"; 4 | import { z } from "zod"; 5 | import type { SandboxProvider } from "./sandbox-provider.js"; 6 | 7 | /** 8 | * Create exploration tools for navigating the sandbox file system 9 | */ 10 | export function createExplorationTools( 11 | sandboxProvider: SandboxProvider, 12 | baseDir: string 13 | ) { 14 | return { 15 | sandbox_ls: tool({ 16 | description: 17 | "List directory contents in the sandbox. Shows files and directories with details. Can explore any directory by providing a path (relative or absolute). Defaults to base directory if no path provided.", 18 | inputSchema: z.object({ 19 | path: z 20 | .string() 21 | .optional() 22 | .default(baseDir) 23 | .describe( 24 | "Directory path to list (e.g., 'mcp/github', '/full/path/to/dir', './subdirectory'). Defaults to base directory. Can explore any subdirectory within the sandbox." 25 | ), 26 | showHidden: z 27 | .boolean() 28 | .optional() 29 | .default(false) 30 | .describe("Show hidden files (starting with .)"), 31 | }), 32 | async execute({ path, showHidden }) { 33 | const args = ["-la", path]; 34 | if (!showHidden) { 35 | args.splice(1, 0, "-A"); // -A shows hidden but not . and .. 36 | } 37 | 38 | const result = await sandboxProvider.runCommand({ 39 | cmd: "ls", 40 | args, 41 | }); 42 | 43 | if (result.exitCode !== 0) { 44 | const stderr = await result.stderr(); 45 | return `Error listing directory: ${stderr || "Unknown error"}`; 46 | } 47 | 48 | return (await result.stdout()) || "Empty directory"; 49 | }, 50 | }), 51 | 52 | sandbox_cat: tool({ 53 | description: 54 | "Read the contents of a file in the sandbox. REQUIRED: You must provide the 'file' parameter with the path to the file.", 55 | inputSchema: z.object({ 56 | file: z.string().describe("Path to the file to read (REQUIRED)"), 57 | }), 58 | async execute({ file }) { 59 | if (!file || file.trim() === "") { 60 | return "Error: 'file' parameter is required and cannot be empty. Please provide the full path to the file you want to read."; 61 | } 62 | 63 | const result = await sandboxProvider.runCommand({ 64 | cmd: "cat", 65 | args: [file], 66 | }); 67 | 68 | if (result.exitCode !== 0) { 69 | const stderr = await result.stderr(); 70 | return `Error reading file: ${stderr || "File not found"}`; 71 | } 72 | 73 | const content = (await result.stdout()) || "Empty file"; 74 | 75 | // If this looks like a compacted tool result JSON, parse it and return just the output 76 | // This prevents the model from echoing large JSON structures 77 | if ( 78 | content.includes('"metadata"') && 79 | content.includes('"toolName"') && 80 | content.includes('"output"') 81 | ) { 82 | try { 83 | const parsed = JSON.parse(content); 84 | if (parsed.output) { 85 | // Return the actual tool output, not the metadata wrapper 86 | return typeof parsed.output === "string" 87 | ? parsed.output 88 | : JSON.stringify(parsed.output, null, 2); 89 | } 90 | } catch (e) { 91 | // If parsing fails, return content as-is 92 | } 93 | } 94 | 95 | return content; 96 | }, 97 | }), 98 | 99 | sandbox_grep: tool({ 100 | description: 101 | "Search for a pattern in files within the sandbox. Can search in any directory or file. REQUIRED: You must provide the 'pattern' parameter.", 102 | inputSchema: z.object({ 103 | pattern: z.string().describe("Pattern to search for (REQUIRED)"), 104 | path: z 105 | .string() 106 | .optional() 107 | .default(baseDir) 108 | .describe( 109 | "Directory or file to search in (e.g., 'mcp', 'user-code/script.ts', or full path). Defaults to base directory." 110 | ), 111 | recursive: z 112 | .boolean() 113 | .optional() 114 | .default(true) 115 | .describe("Search recursively in subdirectories"), 116 | caseInsensitive: z 117 | .boolean() 118 | .optional() 119 | .default(false) 120 | .describe("Case-insensitive search"), 121 | }), 122 | async execute({ pattern, path, recursive, caseInsensitive }) { 123 | if (!pattern || pattern.trim() === "") { 124 | return "Error: 'pattern' parameter is required and cannot be empty. Please provide a search pattern."; 125 | } 126 | 127 | const args: string[] = ["-n"]; // Show line numbers 128 | 129 | if (recursive) { 130 | args.push("-r"); 131 | } 132 | if (caseInsensitive) { 133 | args.push("-i"); 134 | } 135 | 136 | args.push(pattern, path); 137 | 138 | const result = await sandboxProvider.runCommand({ 139 | cmd: "grep", 140 | args, 141 | }); 142 | 143 | // grep returns exit code 1 when no matches found, which is not an error 144 | if (result.exitCode !== 0 && result.exitCode !== 1) { 145 | const stderr = await result.stderr(); 146 | return `Error searching: ${stderr || "Unknown error"}`; 147 | } 148 | 149 | const stdout = await result.stdout(); 150 | if (!stdout || stdout.trim() === "") { 151 | return `No matches found for pattern: ${pattern}`; 152 | } 153 | 154 | return stdout; 155 | }, 156 | }), 157 | 158 | sandbox_find: tool({ 159 | description: 160 | "Find files by name pattern in the sandbox. Can search in any directory. Use wildcards like *.json to find files. REQUIRED: You must provide the 'pattern' parameter.", 161 | inputSchema: z.object({ 162 | pattern: z 163 | .string() 164 | .describe( 165 | "File name pattern (e.g., '*.json', 'fetchEmails.*', '*.ts') (REQUIRED)" 166 | ), 167 | path: z 168 | .string() 169 | .optional() 170 | .default(baseDir) 171 | .describe( 172 | "Directory to search in (e.g., 'mcp', 'local-tools', or full path). Defaults to base directory. Searches recursively in subdirectories." 173 | ), 174 | }), 175 | async execute({ pattern, path }) { 176 | if (!pattern || pattern.trim() === "") { 177 | return "Error: 'pattern' parameter is required and cannot be empty. Please provide a file name pattern."; 178 | } 179 | 180 | const result = await sandboxProvider.runCommand({ 181 | cmd: "find", 182 | args: [path, "-name", pattern], 183 | }); 184 | 185 | if (result.exitCode !== 0) { 186 | const stderr = await result.stderr(); 187 | return `Error finding files: ${stderr || "Unknown error"}`; 188 | } 189 | 190 | const stdout = await result.stdout(); 191 | if (!stdout || stdout.trim() === "") { 192 | return `No files found matching: ${pattern}`; 193 | } 194 | 195 | return stdout; 196 | }, 197 | }), 198 | }; 199 | } 200 | 201 | /** 202 | * Create execution tool for running code in the sandbox 203 | */ 204 | export function createExecutionTool( 205 | sandboxProvider: SandboxProvider, 206 | userCodeDir: string 207 | ) { 208 | return { 209 | sandbox_exec: tool({ 210 | description: `Execute TypeScript code in the sandbox using tsx. Code is saved in ${userCodeDir}. IMPORTS: From user-code, use '../mcp/server-name/' for MCP tools, '../local-tools/' for local tools, and '../compact/' for compacted results. REQUIRED: You must provide the 'code' parameter with valid TypeScript code to execute.`, 211 | inputSchema: z.object({ 212 | code: z 213 | .string() 214 | .describe( 215 | "TypeScript code to execute (REQUIRED). IMPORTS: Use '../mcp/server-name/' for MCP tools, '../local-tools/' for local tools, '../compact/' for compacted results." 216 | ), 217 | filename: z 218 | .string() 219 | .optional() 220 | .default("script.ts") 221 | .describe( 222 | `Filename to save in ${userCodeDir}. Defaults to 'script.ts' if not provided.` 223 | ), 224 | }), 225 | async execute({ code, filename }) { 226 | if (!code || code.trim() === "") { 227 | return "Error: 'code' parameter is required and cannot be empty. Please provide valid TypeScript code to execute."; 228 | } 229 | const scriptPath = `${userCodeDir}/${filename}`; 230 | 231 | try { 232 | // Ensure user-code directory exists 233 | await sandboxProvider.runCommand({ 234 | cmd: "mkdir", 235 | args: ["-p", userCodeDir], 236 | }); 237 | 238 | // Write the code to a file 239 | await sandboxProvider.writeFiles([ 240 | { 241 | path: scriptPath, 242 | content: Buffer.from(code, "utf-8"), 243 | }, 244 | ]); 245 | 246 | // Execute with timeout 247 | const execStartTime = Date.now(); 248 | const executionPromise = sandboxProvider.runCommand({ 249 | cmd: "npx", 250 | args: ["tsx", scriptPath], 251 | }); 252 | 253 | const timeoutPromise = new Promise((_, reject) => { 254 | setTimeout( 255 | () => reject(new Error("Execution timeout after 60 seconds")), 256 | 60000 257 | ); 258 | }); 259 | 260 | let result; 261 | try { 262 | result = await Promise.race([executionPromise, timeoutPromise]); 263 | } catch (raceError) { 264 | throw raceError; 265 | } 266 | 267 | // Combine stdout and stderr for complete output 268 | const stdout = await result.stdout(); 269 | const stderr = await result.stderr(); 270 | const output = [stdout, stderr].filter(Boolean).join("\n"); 271 | 272 | if (result.exitCode !== 0) { 273 | return `⚠️ Script execution failed (exit code ${result.exitCode}) 274 | 275 | File: ${scriptPath} 276 | 277 | 📋 Full Output (stdout + stderr): 278 | ${output || "(no output)"} 279 | 280 | 💡 Tip: Check for syntax errors, missing imports, or runtime errors above. Fix the code and try again.`; 281 | } 282 | 283 | return `✓ Execution successful 284 | File: ${scriptPath} 285 | 286 | Output: 287 | ${stdout || "(no output)"}${stderr ? `\n\nWarnings/Info:\n${stderr}` : ""}`; 288 | } catch (error) { 289 | // Try to get stdout/stderr from the error if it's a CommandExitError 290 | let errorDetails = ""; 291 | let capturedOutput = ""; 292 | 293 | if (error && typeof error === "object" && "result" in error) { 294 | const result = (error as any).result; 295 | if (result) { 296 | try { 297 | const stdout = result.stdout 298 | ? typeof result.stdout === "function" 299 | ? await result.stdout() 300 | : result.stdout 301 | : ""; 302 | const stderr = result.stderr 303 | ? typeof result.stderr === "function" 304 | ? await result.stderr() 305 | : result.stderr 306 | : ""; 307 | 308 | errorDetails = ` 309 | Exit Code: ${result.exitCode || "unknown"} 310 | Stdout: ${stdout || "(empty)"} 311 | Stderr: ${stderr || "(empty)"}`; 312 | capturedOutput = [stdout, stderr].filter(Boolean).join("\n"); 313 | } catch (e) { 314 | // Ignore extraction errors 315 | } 316 | } 317 | } 318 | 319 | return `⚠️ Execution error: ${ 320 | error instanceof Error ? error.message : String(error) 321 | }${errorDetails}${ 322 | capturedOutput ? `\n\nCaptured Output:\n${capturedOutput}` : "" 323 | } 324 | 325 | This is a system-level error (not a code error). The script couldn't be executed. Check if the file was written correctly or if there's a sandbox issue.`; 326 | } 327 | }, 328 | }), 329 | 330 | sandbox_write_file: tool({ 331 | description: `Write or overwrite a file in the user code directory (${userCodeDir}). Creates the file if it doesn't exist. SECURITY: Only works in user-code directory. IMPORTS: From user-code, use '../mcp/server-name/' for MCP tools, '../local-tools/' for local tools, and '../compact/' for compacted results.`, 332 | inputSchema: z.object({ 333 | filename: z 334 | .string() 335 | .describe( 336 | "Name of the file to write (e.g., 'helper.ts', 'utils.ts'). Will be created in user-code directory." 337 | ), 338 | content: z 339 | .string() 340 | .describe( 341 | "The full content to write to the file. When importing: use '../mcp/server-name/' for MCP tools, '../local-tools/' for local tools, '../compact/' for compacted results." 342 | ), 343 | }), 344 | async execute({ filename, content }) { 345 | if (!filename || filename.trim() === "") { 346 | return "Error: 'filename' parameter is required and cannot be empty."; 347 | } 348 | if (content === undefined || content === null) { 349 | return "Error: 'content' parameter is required."; 350 | } 351 | 352 | try { 353 | // Security: Block path traversal attempts 354 | if (filename.includes("..") || filename.includes("/")) { 355 | return `Error: Invalid filename '${filename}'. Only simple filenames are allowed (no paths or '..'). The file will be created in ${userCodeDir}.`; 356 | } 357 | 358 | const filePath = `${userCodeDir}/${filename}`; 359 | 360 | // Ensure user-code directory exists 361 | await sandboxProvider.runCommand({ 362 | cmd: "mkdir", 363 | args: ["-p", userCodeDir], 364 | }); 365 | 366 | // Write the file 367 | await sandboxProvider.writeFiles([ 368 | { 369 | path: filePath, 370 | content: Buffer.from(content, "utf-8"), 371 | }, 372 | ]); 373 | 374 | return `✓ File written successfully: ${filePath}`; 375 | } catch (error) { 376 | return `Error writing file: ${ 377 | error instanceof Error ? error.message : String(error) 378 | }`; 379 | } 380 | }, 381 | }), 382 | 383 | sandbox_edit_file: tool({ 384 | description: `Edit a file in the user code directory by replacing text. Replaces 'old_text' with 'new_text'. SECURITY: Only works on files in user-code directory (${userCodeDir}). IMPORTS: From user-code, use '../mcp/server-name/' for MCP tools, '../local-tools/' for local tools, and '../compact/' for compacted results.`, 385 | inputSchema: z.object({ 386 | filename: z 387 | .string() 388 | .describe( 389 | "Name of the file to edit in user-code directory (e.g., 'script.ts')" 390 | ), 391 | old_text: z 392 | .string() 393 | .describe( 394 | "The exact text to find and replace. Must match exactly (including whitespace)." 395 | ), 396 | new_text: z 397 | .string() 398 | .describe( 399 | "The text to replace old_text with. When importing: use '../mcp/server-name/' for MCP tools, '../local-tools/' for local tools, '../compact/' for compacted results." 400 | ), 401 | }), 402 | async execute({ filename, old_text, new_text }) { 403 | if (!filename || filename.trim() === "") { 404 | return "Error: 'filename' parameter is required and cannot be empty."; 405 | } 406 | if (old_text === undefined || old_text === null) { 407 | return "Error: 'old_text' parameter is required."; 408 | } 409 | if (new_text === undefined || new_text === null) { 410 | return "Error: 'new_text' parameter is required."; 411 | } 412 | 413 | try { 414 | // Security: Block path traversal attempts 415 | if (filename.includes("..") || filename.includes("/")) { 416 | return `Error: Invalid filename '${filename}'. Only simple filenames in ${userCodeDir} are allowed.`; 417 | } 418 | 419 | const filePath = `${userCodeDir}/${filename}`; 420 | 421 | // Read the current file 422 | const catResult = await sandboxProvider.runCommand({ 423 | cmd: "cat", 424 | args: [filePath], 425 | }); 426 | 427 | if (catResult.exitCode !== 0) { 428 | const stderr = await catResult.stderr(); 429 | return `Error: File not found or cannot be read: ${filePath}\n${stderr}`; 430 | } 431 | 432 | const currentContent = await catResult.stdout(); 433 | 434 | // Check if old_text exists in the file 435 | if (!currentContent.includes(old_text)) { 436 | return `Error: The text to replace was not found in ${filePath}.\n\nSearched for:\n${old_text.substring( 437 | 0, 438 | 200 439 | )}...\n\nMake sure the text matches exactly (including whitespace).`; 440 | } 441 | 442 | // Check for multiple matches 443 | const matches = currentContent.split(old_text).length - 1; 444 | if (matches > 1) { 445 | return `Error: Found ${matches} matches for the text in ${filePath}. Please provide more specific text that matches only once.`; 446 | } 447 | 448 | // Perform the replacement 449 | const newContent = currentContent.replace(old_text, new_text); 450 | 451 | // Write the modified content back 452 | await sandboxProvider.writeFiles([ 453 | { 454 | path: filePath, 455 | content: Buffer.from(newContent, "utf-8"), 456 | }, 457 | ]); 458 | 459 | return `✓ File edited successfully: ${filePath}\n\nReplaced:\n${old_text.substring( 460 | 0, 461 | 100 462 | )}...\n\nWith:\n${new_text.substring(0, 100)}...`; 463 | } catch (error) { 464 | return `Error editing file: ${ 465 | error instanceof Error ? error.message : String(error) 466 | }`; 467 | } 468 | }, 469 | }), 470 | 471 | sandbox_delete_file: tool({ 472 | description: `Delete a file in the user code directory (${userCodeDir}). SECURITY: Only works in user-code directory. NOTE: From user-code, relative imports are '../mcp/server-name/' for MCP tools, '../local-tools/' for local tools, and '../compact/' for compacted results.`, 473 | inputSchema: z.object({ 474 | filename: z 475 | .string() 476 | .describe("Name of the file to delete in user-code directory"), 477 | }), 478 | async execute({ filename }) { 479 | if (!filename || filename.trim() === "") { 480 | return "Error: 'filename' parameter is required and cannot be empty."; 481 | } 482 | 483 | const filePath = `${userCodeDir}/${filename}`; 484 | 485 | try { 486 | await sandboxProvider.runCommand({ 487 | cmd: "rm", 488 | args: [filePath], 489 | }); 490 | 491 | return `✓ File deleted successfully: ${filePath}`; 492 | } catch (error) { 493 | return `Error deleting file: ${ 494 | error instanceof Error ? error.message : String(error) 495 | }`; 496 | } 497 | }, 498 | }), 499 | 500 | sandbox_lint: tool({ 501 | description: `Lint a TypeScript file in ${userCodeDir} and check for errors without executing it. Use this after writing code with sandbox_write_file or sandbox_exec to validate the code. Uses TypeScript compiler to check for type errors, syntax errors, and other issues.`, 502 | inputSchema: z.object({ 503 | filename: z 504 | .string() 505 | .describe( 506 | `Filename in ${userCodeDir} to lint (e.g., 'script.ts', 'helper.ts')` 507 | ), 508 | }), 509 | async execute({ filename }) { 510 | if (!filename || filename.trim() === "") { 511 | return "Error: 'filename' parameter is required and cannot be empty."; 512 | } 513 | 514 | try { 515 | // Security: Block path traversal 516 | if (filename.includes("..") || filename.includes("/")) { 517 | return `Error: Invalid filename '${filename}'. Only simple filenames in ${userCodeDir} are allowed.`; 518 | } 519 | 520 | const filePath = `${userCodeDir}/${filename}`; 521 | 522 | // Check if file exists 523 | const checkResult = await sandboxProvider.runCommand({ 524 | cmd: "test", 525 | args: ["-f", filePath], 526 | }); 527 | 528 | if (checkResult.exitCode !== 0) { 529 | return `Error: File not found: ${filePath}`; 530 | } 531 | 532 | // Run TypeScript compiler in no-emit mode to check for errors 533 | const result = await sandboxProvider.runCommand({ 534 | cmd: "npx", 535 | args: [ 536 | "tsc", 537 | "--noEmit", 538 | "--pretty", 539 | "false", 540 | "--skipLibCheck", 541 | filePath, 542 | ], 543 | }); 544 | 545 | const stdout = await result.stdout(); 546 | const stderr = await result.stderr(); 547 | const output = [stdout, stderr].filter(Boolean).join("\n"); 548 | 549 | if (result.exitCode === 0) { 550 | return `✓ No TypeScript errors found\n\nFile: ${filePath}\n\nThe code passes TypeScript compilation checks.`; 551 | } 552 | 553 | return `⚠️ TypeScript errors found\n\nFile: ${filePath}\n\n${ 554 | output || "(no error details available)" 555 | }\n\n💡 Fix these errors before executing the code.`; 556 | } catch (error) { 557 | return `Error during linting: ${ 558 | error instanceof Error ? error.message : String(error) 559 | }`; 560 | } 561 | }, 562 | }), 563 | }; 564 | } 565 | -------------------------------------------------------------------------------- /src/sandbox-code-generator/file-generator.ts: -------------------------------------------------------------------------------- 1 | // Generate TypeScript files in the sandbox 2 | 3 | import type { SandboxProvider } from "./sandbox-provider.js"; 4 | import { 5 | extractJSDocFromSchema, 6 | generateTypeScriptInterface, 7 | } from "./schema-converter.js"; 8 | import type { 9 | MCPServerConfig, 10 | ServerToolsMap, 11 | ToolDefinition, 12 | } from "./types.js"; 13 | 14 | /** 15 | * Sanitize tool name to be a valid TypeScript identifier 16 | */ 17 | function sanitizeToolName(name: string): string { 18 | return name.replace(/[^a-zA-Z0-9_]/g, "_"); 19 | } 20 | 21 | /** 22 | * Generate a realistic usage example from the schema 23 | */ 24 | function generateUsageExample(functionName: string, schema: any): string { 25 | if (!schema || !schema.properties) { 26 | return `const result = await ${functionName}({});`; 27 | } 28 | 29 | const exampleArgs: Record = {}; 30 | const required = schema.required || []; 31 | 32 | for (const [propName, propSchema] of Object.entries(schema.properties)) { 33 | const prop = propSchema as any; 34 | 35 | // Only include required fields or first few fields 36 | if (!required.includes(propName) && Object.keys(exampleArgs).length >= 2) { 37 | continue; 38 | } 39 | 40 | // Generate example values based on type and description 41 | if (prop.type === "string") { 42 | if ( 43 | prop.description?.toLowerCase().includes("query") || 44 | prop.description?.toLowerCase().includes("search") 45 | ) { 46 | exampleArgs[propName] = "your search query"; 47 | } else if (prop.description?.toLowerCase().includes("repo")) { 48 | exampleArgs[propName] = "owner/repository"; 49 | } else if (prop.description?.toLowerCase().includes("url")) { 50 | exampleArgs[propName] = "https://example.com"; 51 | } else { 52 | exampleArgs[propName] = `example ${propName}`; 53 | } 54 | } else if (prop.type === "number" || prop.type === "integer") { 55 | exampleArgs[propName] = 10; 56 | } else if (prop.type === "boolean") { 57 | exampleArgs[propName] = true; 58 | } else if (prop.type === "array") { 59 | if (prop.description?.toLowerCase().includes("language")) { 60 | exampleArgs[propName] = ["TypeScript", "JavaScript"]; 61 | } else { 62 | exampleArgs[propName] = ["item1", "item2"]; 63 | } 64 | } 65 | } 66 | 67 | return `// Always log the response to understand its structure 68 | const result = await ${functionName}(${JSON.stringify(exampleArgs, null, 2)}); 69 | console.log('Response:', JSON.stringify(result, null, 2)); 70 | 71 | // Then process based on actual structure 72 | // Note: result is typically an object, not an array!`; 73 | } 74 | 75 | /** 76 | * Generate a single tool file 77 | */ 78 | export function generateToolFile( 79 | tool: ToolDefinition, 80 | serverName: string 81 | ): string { 82 | const functionName = sanitizeToolName(tool.name); 83 | const inputInterfaceName = `${functionName 84 | .charAt(0) 85 | .toUpperCase()}${functionName.slice(1)}Input`; 86 | const outputInterfaceName = `${functionName 87 | .charAt(0) 88 | .toUpperCase()}${functionName.slice(1)}Output`; 89 | 90 | const jsdoc = extractJSDocFromSchema( 91 | tool.inputSchema, 92 | functionName, 93 | tool.description 94 | ); 95 | const inputInterface = generateTypeScriptInterface( 96 | tool.inputSchema, 97 | inputInterfaceName 98 | ); 99 | 100 | // Generate usage example (simpler to avoid nested template issues) 101 | const exampleUsage = generateUsageExample(functionName, tool.inputSchema); 102 | 103 | return `import { callMCPTool } from '../_client.ts'; 104 | 105 | ${jsdoc} 106 | ${inputInterface} 107 | 108 | export interface ${outputInterfaceName} { 109 | [key: string]: any; 110 | } 111 | 112 | /** 113 | * @example 114 | * ${exampleUsage} 115 | */ 116 | export async function ${functionName}( 117 | input: ${inputInterfaceName} 118 | ): Promise<${outputInterfaceName}> { 119 | return callMCPTool<${outputInterfaceName}>('${serverName}', '${tool.name}', input); 120 | } 121 | `; 122 | } 123 | 124 | /** 125 | * Generate server index file that exports all tools 126 | */ 127 | export function generateServerIndex( 128 | tools: ToolDefinition[], 129 | serverName: string 130 | ): string { 131 | const exports = tools 132 | .map((tool) => { 133 | const functionName = sanitizeToolName(tool.name); 134 | return `export { ${functionName} } from './${functionName}.ts';`; 135 | }) 136 | .join("\n"); 137 | 138 | return `// Auto-generated index for ${serverName} MCP server tools 139 | 140 | ${exports} 141 | `; 142 | } 143 | 144 | /** 145 | * Generate the MCP client router that connects to all servers 146 | */ 147 | export function generateMCPClient(servers: MCPServerConfig[]): string { 148 | const serverConfigsObj = servers.reduce((acc, server) => { 149 | acc[server.name] = { 150 | url: server.url, 151 | useSSE: server.useSSE || false, 152 | headers: server.headers || {}, 153 | }; 154 | return acc; 155 | }, {} as Record); 156 | 157 | return `import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 158 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; 159 | import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; 160 | 161 | console.log('[MCP Client] Module loaded, initializing...'); 162 | 163 | const serverConfigs = ${JSON.stringify(serverConfigsObj, null, 2)}; 164 | 165 | console.log('[MCP Client] Server configs loaded:', Object.keys(serverConfigs).join(', ')); 166 | 167 | const clients = new Map(); 168 | 169 | console.log('[MCP Client] Ready to accept requests'); 170 | 171 | async function getClient(serverName: string): Promise { 172 | console.log(\`[MCP Client] getClient called for: \${serverName}\`); 173 | 174 | if (clients.has(serverName)) { 175 | console.log(\`[MCP Client] Using cached client for: \${serverName}\`); 176 | return clients.get(serverName)!; 177 | } 178 | 179 | console.log(\`[MCP Client] Creating new client for: \${serverName}\`); 180 | const config = serverConfigs[serverName as keyof typeof serverConfigs]; 181 | if (!config) { 182 | console.error(\`[MCP Client] Unknown server: \${serverName}\`); 183 | console.error(\`[MCP Client] Available servers: \${Object.keys(serverConfigs).join(', ')}\`); 184 | throw new Error(\`Unknown MCP server: \${serverName}\`); 185 | } 186 | 187 | console.log(\`[MCP Client] Server config:\`, JSON.stringify(config, null, 2)); 188 | 189 | const requestInit = Object.keys(config.headers).length > 0 190 | ? { headers: config.headers } 191 | : undefined; 192 | 193 | console.log(\`[MCP Client] Using transport: \${config.useSSE ? 'SSE' : 'StreamableHTTP'}\`); 194 | console.log(\`[MCP Client] Target URL: \${config.url}\`); 195 | console.log(\`[MCP Client] Headers present: \${Object.keys(config.headers).length > 0 ? 'yes' : 'no'}\`); 196 | 197 | const transport = config.useSSE 198 | ? new SSEClientTransport(new URL(config.url), requestInit ? { requestInit } : undefined) 199 | : new StreamableHTTPClientTransport(new URL(config.url), { requestInit }); 200 | 201 | console.log(\`[MCP Client] Transport created, initializing client...\`); 202 | 203 | const client = new Client( 204 | { name: 'sandbox-mcp', version: '1.0.0' }, 205 | { capabilities: {} } 206 | ); 207 | 208 | console.log(\`[MCP Client] Connecting to \${serverName}...\`); 209 | const connectStart = Date.now(); 210 | 211 | try { 212 | await client.connect(transport); 213 | const connectTime = Date.now() - connectStart; 214 | console.log(\`[MCP Client] ✓ Connected to \${serverName} in \${connectTime}ms\`); 215 | clients.set(serverName, client); 216 | return client; 217 | } catch (error) { 218 | const connectTime = Date.now() - connectStart; 219 | console.error(\`[MCP Client] ✗ Connection failed to \${serverName} after \${connectTime}ms\`); 220 | console.error(\`[MCP Client] Error type: \${error instanceof Error ? error.constructor.name : typeof error}\`); 221 | console.error(\`[MCP Client] Error message: \${error instanceof Error ? error.message : String(error)}\`); 222 | if (error instanceof Error && error.stack) { 223 | console.error(\`[MCP Client] Stack trace:\`, error.stack.split('\\n').slice(0, 5).join('\\n')); 224 | } 225 | throw error; 226 | } 227 | } 228 | 229 | function withTimeout(promise: Promise, timeoutMs: number, operation: string): Promise { 230 | return Promise.race([ 231 | promise, 232 | new Promise((_, reject) => 233 | setTimeout(() => reject(new Error(\`\${operation} timeout after \${timeoutMs}ms\`)), timeoutMs) 234 | ) 235 | ]); 236 | } 237 | 238 | export async function callMCPTool( 239 | serverName: string, 240 | toolName: string, 241 | args: any 242 | ): Promise { 243 | console.log(\`\\n[MCP Tool Call] ========================================\`); 244 | console.log(\`[MCP Tool Call] Server: \${serverName}\`); 245 | console.log(\`[MCP Tool Call] Tool: \${toolName}\`); 246 | console.log(\`[MCP Tool Call] Args:\`, JSON.stringify(args, null, 2)); 247 | 248 | try { 249 | console.log(\`[MCP Tool Call] Getting client for \${serverName}...\`); 250 | const clientStart = Date.now(); 251 | const client = await withTimeout( 252 | getClient(serverName), 253 | 10000, 254 | 'MCP client connection' 255 | ); 256 | const clientTime = Date.now() - clientStart; 257 | console.log(\`[MCP Tool Call] ✓ Got client in \${clientTime}ms\`); 258 | 259 | console.log(\`[MCP Tool Call] Calling tool '\${toolName}'...\`); 260 | const toolStart = Date.now(); 261 | const result = await withTimeout( 262 | client.callTool({ name: toolName, arguments: args }), 263 | 20000, 264 | \`MCP tool call '\${toolName}'\` 265 | ); 266 | const toolTime = Date.now() - toolStart; 267 | console.log(\`[MCP Tool Call] ✓ Tool call completed in \${toolTime}ms\`); 268 | console.log(\`[MCP Tool Call] Raw result type: \${typeof result}\`); 269 | console.log(\`[MCP Tool Call] Raw result keys: \${Object.keys(result || {}).join(', ')}\`); 270 | 271 | // Parse MCP response 272 | if (Array.isArray(result.content)) { 273 | console.log(\`[MCP Tool Call] Content is array with \${result.content.length} items\`); 274 | const text = result.content 275 | .map(item => typeof item === 'string' ? item : JSON.stringify(item)) 276 | .join('\\n'); 277 | console.log(\`[MCP Tool Call] Combined text length: \${text.length}\`); 278 | try { 279 | const parsed = JSON.parse(text) as T; 280 | console.log(\`[MCP Tool Call] ✓ Successfully parsed as JSON\`); 281 | return parsed; 282 | } catch { 283 | console.log(\`[MCP Tool Call] Returning as text (not valid JSON)\`); 284 | return text as T; 285 | } 286 | } 287 | 288 | if (typeof result.content === 'string') { 289 | console.log(\`[MCP Tool Call] Content is string with length: \${result.content.length}\`); 290 | try { 291 | const parsed = JSON.parse(result.content) as T; 292 | console.log(\`[MCP Tool Call] ✓ Successfully parsed as JSON\`); 293 | return parsed; 294 | } catch { 295 | console.log(\`[MCP Tool Call] Returning as text (not valid JSON)\`); 296 | return result.content as T; 297 | } 298 | } 299 | 300 | console.log(\`[MCP Tool Call] Returning content as-is\`); 301 | return result.content as T; 302 | } catch (error) { 303 | console.error(\`\\n[MCP Client Error] ========================================\`); 304 | console.error(\`[MCP Client Error] Server: \${serverName}, Tool: \${toolName}\`); 305 | console.error(\`[MCP Client Error] Error type: \${error instanceof Error ? error.constructor.name : typeof error}\`); 306 | console.error(\`[MCP Client Error] Error message: \${error instanceof Error ? error.message : String(error)}\`); 307 | if (error instanceof Error && error.stack) { 308 | console.error(\`[MCP Client Error] Stack trace:\`, error.stack.split('\\n').slice(0, 5).join('\\n')); 309 | } 310 | console.error(\`[MCP Client Error] ========================================\\n\`); 311 | throw error; 312 | } 313 | } 314 | 315 | // Cleanup function to close all MCP client connections 316 | export async function closeAllConnections(): Promise { 317 | console.log('[MCP Client] Closing all connections...'); 318 | for (const [serverName, client] of clients.entries()) { 319 | try { 320 | await client.close(); 321 | console.log(\`[MCP Client] ✓ Closed connection to \${serverName}\`); 322 | } catch (err) { 323 | console.error(\`[MCP Client] Error closing \${serverName}:\`, err); 324 | } 325 | } 326 | clients.clear(); 327 | console.log('[MCP Client] All connections closed'); 328 | } 329 | 330 | // Auto-cleanup on process exit 331 | if (typeof process !== 'undefined') { 332 | process.on('beforeExit', () => { 333 | closeAllConnections().catch(console.error); 334 | }); 335 | 336 | // Force cleanup after 15 seconds of inactivity (enough time for slow API calls) 337 | let lastActivityTime = Date.now(); 338 | let cleanupTimer: NodeJS.Timeout | null = null; 339 | 340 | const resetCleanupTimer = () => { 341 | lastActivityTime = Date.now(); 342 | if (cleanupTimer) clearTimeout(cleanupTimer); 343 | cleanupTimer = setTimeout(async () => { 344 | const idleTime = Date.now() - lastActivityTime; 345 | if (idleTime >= 15000) { 346 | console.log('[MCP Client] Auto-cleanup: No activity for 15s, closing connections...'); 347 | await closeAllConnections(); 348 | process.exit(0); 349 | } 350 | }, 16000); 351 | }; 352 | 353 | // Monitor console output to detect when script is done 354 | const originalLog = console.log; 355 | const originalError = console.error; 356 | console.log = (...args: any[]) => { 357 | resetCleanupTimer(); 358 | originalLog(...args); 359 | }; 360 | console.error = (...args: any[]) => { 361 | resetCleanupTimer(); 362 | originalError(...args); 363 | }; 364 | 365 | // Start the timer 366 | resetCleanupTimer(); 367 | } 368 | `; 369 | } 370 | 371 | /** 372 | * Generate README with usage instructions 373 | */ 374 | function generateREADME( 375 | serverToolsMap: ServerToolsMap, 376 | servers: MCPServerConfig[] 377 | ): string { 378 | const totalTools = Object.values(serverToolsMap).reduce( 379 | (sum, tools) => sum + tools.length, 380 | 0 381 | ); 382 | 383 | let readme = `# MCP Tool Definitions 384 | 385 | This directory contains TypeScript definitions for ${totalTools} MCP tool(s) from ${servers.length} server(s). 386 | 387 | ## 🗂️ Structure 388 | 389 | \`\`\` 390 | . 391 | ├── _client.ts # MCP client for calling tools 392 | ├── README.md # This file 393 | `; 394 | 395 | // Add server directories 396 | for (const [serverName, tools] of Object.entries(serverToolsMap)) { 397 | readme += `└── ${serverName}/\n`; 398 | readme += ` ├── index.ts # Exports all tools\n`; 399 | tools.forEach((tool) => { 400 | const fileName = sanitizeToolName(tool.name); 401 | readme += ` └── ${fileName}.ts\n`; 402 | }); 403 | } 404 | 405 | readme += `\`\`\` 406 | 407 | ## 🚀 Quick Start 408 | 409 | All tools can be imported from their server directory: 410 | 411 | \`\`\`typescript 412 | // Import from server directory 413 | `; 414 | 415 | // Show first tool from each server as example 416 | for (const [serverName, tools] of Object.entries(serverToolsMap)) { 417 | if (tools.length > 0) { 418 | const firstTool = sanitizeToolName(tools[0].name); 419 | readme += `import { ${firstTool} } from './${serverName}/index.ts';\n`; 420 | } 421 | } 422 | 423 | readme += ` 424 | 425 | // Use the tools 426 | `; 427 | 428 | // Show usage example for first tool 429 | for (const [serverName, tools] of Object.entries(serverToolsMap)) { 430 | if (tools.length > 0) { 431 | const tool = tools[0]; 432 | const functionName = sanitizeToolName(tool.name); 433 | const example = generateUsageExample(functionName, tool.inputSchema); 434 | readme += `${example}\n`; 435 | break; // Just show one example 436 | } 437 | } 438 | 439 | readme += `\`\`\` 440 | 441 | ## 📚 Available Tools 442 | 443 | `; 444 | 445 | // List all tools by server 446 | for (const [serverName, tools] of Object.entries(serverToolsMap)) { 447 | readme += `### ${serverName}\n\n`; 448 | tools.forEach((tool) => { 449 | const functionName = sanitizeToolName(tool.name); 450 | readme += `- **${functionName}**: ${ 451 | tool.description || "No description" 452 | }\n`; 453 | }); 454 | readme += `\n`; 455 | } 456 | 457 | readme += `## 💡 Tips for Using These Tools 458 | 459 | 1. **Check the type definitions**: Each tool file contains TypeScript interfaces with full JSDoc 460 | 2. **Look at examples**: Each function has an @example in its JSDoc 461 | 3. **Import from index**: Use \`import { toolName } from './server-name/index.ts'\` 462 | 4. **Relative imports**: When writing scripts in ../user-code/, use \`'../mcp/...'\` 463 | 5. **⚠️ IMPORTANT - Response Handling**: 464 | - **Always log the response first** to understand its structure! 465 | - MCP tools return **objects or strings, NOT arrays** 466 | - Use \`console.log('Response:', JSON.stringify(result, null, 2));\` before processing 467 | - Don't assume array methods like \`.map()\` or \`.length\` will work 468 | - Parse the actual response structure (often \`{ type: "text", text: "..." }\` or plain strings) 469 | 6. **🔄 CRITICAL - Clean Exit**: 470 | - **Always call \`closeAllConnections()\` before exiting** to close MCP connections 471 | - Import from \`_client.ts\`: \`import { closeAllConnections } from './mcp/_client.ts';\` 472 | - Call in \`finally\` block: \`finally { await closeAllConnections(); }\` 473 | - **Without this, your script may hang for 60+ seconds!** 474 | 475 | ## 📖 Recommended Script Pattern 476 | 477 | \`\`\`typescript 478 | import { toolName } from './mcp/server-name/index.ts'; 479 | import { closeAllConnections } from './mcp/_client.ts'; 480 | 481 | async function main() { 482 | try { 483 | // Your MCP tool calls here 484 | const result = await toolName({ /* args */ }); 485 | console.log('Result:', JSON.stringify(result, null, 2)); 486 | } catch (error) { 487 | console.error('Error:', error); 488 | } finally { 489 | // Critical: Close connections for immediate exit 490 | await closeAllConnections(); 491 | } 492 | } 493 | 494 | main().catch(console.error); 495 | \`\`\` 496 | 497 | ## 🔧 Implementation Details 498 | 499 | All tools use the \`callMCPTool\` function from \`_client.ts\` which handles: 500 | - MCP server connections (via HTTP or SSE) 501 | - Request/response serialization 502 | - Error handling and timeouts 503 | - Type safety 504 | - Connection pooling and reuse 505 | 506 | The \`closeAllConnections()\` function ensures all MCP clients are properly closed, preventing the Node.js event loop from keeping the process alive. 507 | 508 | See individual tool files for detailed parameter descriptions and usage examples. 509 | `; 510 | 511 | return readme; 512 | } 513 | 514 | /** 515 | * Generate README for user-code directory 516 | */ 517 | function generateUserCodeREADME(): string { 518 | return `# User Code Directory 519 | 520 | This is your workspace for writing TypeScript code that uses MCP tools and local tools. 521 | 522 | ## 📁 Available Resources 523 | 524 | Before writing code here, **explore the available tools** if you haven't already: 525 | 526 | ### 1. MCP Tools (../mcp/) 527 | MCP (Model Context Protocol) tools provide access to external services and APIs. 528 | 529 | **Explore first:** 530 | \`\`\`bash 531 | # List available MCP tool servers 532 | ls ../mcp/ 533 | 534 | # Read the MCP tools README for full details 535 | cat ../mcp/README.md 536 | 537 | # List tools in a specific server (e.g., 'github') 538 | ls ../mcp/github/ 539 | \`\`\` 540 | 541 | **Then import and use:** 542 | \`\`\`typescript 543 | import { toolName } from '../mcp/server-name/index.ts'; 544 | import { closeAllConnections } from '../mcp/_client.ts'; 545 | 546 | async function main() { 547 | try { 548 | const result = await toolName({ /* your args */ }); 549 | console.log('Result:', JSON.stringify(result, null, 2)); 550 | } finally { 551 | // CRITICAL: Always close connections to exit cleanly 552 | await closeAllConnections(); 553 | } 554 | } 555 | 556 | main().catch(console.error); 557 | \`\`\` 558 | 559 | ### 2. Local Tools (../local-tools/) 560 | Local tools are custom utilities available in the sandbox. 561 | 562 | **Explore first:** 563 | \`\`\`bash 564 | # List available local tools 565 | ls ../local-tools/ 566 | 567 | # Read the local tools README (if available) 568 | cat ../local-tools/README.md 569 | 570 | # View a specific tool 571 | cat ../local-tools/toolName.ts 572 | \`\`\` 573 | 574 | **Then import and use:** 575 | \`\`\`typescript 576 | import { functionName } from '../local-tools/toolName.ts'; 577 | 578 | const result = functionName({ /* your args */ }); 579 | console.log(result); 580 | \`\`\` 581 | 582 | ### 3. Compacted Results (../compact/) 583 | If tool results have been compacted to save context, they're stored here. 584 | 585 | **Explore:** 586 | \`\`\`bash 587 | # List compacted files 588 | ls ../compact/ 589 | 590 | # Read a compacted result 591 | cat ../compact/toolName_timestamp.json 592 | \`\`\` 593 | 594 | **Then import and use:** 595 | \`\`\`typescript 596 | import compactedData from '../compact/toolName_timestamp.json' assert { type: 'json' }; 597 | console.log(compactedData); 598 | \`\`\` 599 | 600 | ## ⚠️ Important Guidelines 601 | 602 | 1. **Explore Before Coding**: Always use \`ls\` and \`cat\` to explore the \`../mcp/\`, \`../local-tools/\`, and \`../compact/\` directories before writing code 603 | 2. **Use Relative Imports**: Import using \`../\` prefix (e.g., \`'../mcp/server/tool.ts'\`) 604 | 3. **Log Responses**: Always log MCP tool responses to understand their structure 605 | 4. **Close Connections**: For MCP tools, always call \`closeAllConnections()\` in a \`finally\` block 606 | 5. **Check Types**: Read the TypeScript interfaces in tool files for parameter and return types 607 | 608 | ## 📝 Example Script Pattern 609 | 610 | \`\`\`typescript 611 | // Import tools 612 | import { mcpTool } from '../mcp/server-name/index.ts'; 613 | import { localTool } from '../local-tools/toolName.ts'; 614 | import { closeAllConnections } from '../mcp/_client.ts'; 615 | 616 | async function main() { 617 | try { 618 | // Use MCP tool 619 | console.log('Calling MCP tool...'); 620 | const mcpResult = await mcpTool({ query: 'example' }); 621 | console.log('MCP Result:', JSON.stringify(mcpResult, null, 2)); 622 | 623 | // Use local tool 624 | console.log('\\nCalling local tool...'); 625 | const localResult = localTool({ input: 'data' }); 626 | console.log('Local Result:', localResult); 627 | 628 | // Process results 629 | // ... your logic here ... 630 | 631 | } catch (error) { 632 | console.error('Error:', error); 633 | throw error; 634 | } finally { 635 | // CRITICAL: Close MCP connections 636 | await closeAllConnections(); 637 | } 638 | } 639 | 640 | // Run the script 641 | main().catch(console.error); 642 | \`\`\` 643 | 644 | ## 🚀 Getting Started 645 | 646 | 1. **First, explore available tools:** 647 | \`\`\`bash 648 | ls ../mcp/ 649 | cat ../mcp/README.md 650 | ls ../local-tools/ 651 | \`\`\` 652 | 653 | 2. **Then create your script** (e.g., \`script.ts\`) 654 | 655 | 3. **Run it** using the sandbox execution tool 656 | 657 | ## 💡 Tips 658 | 659 | - Read README files in \`../mcp/\` and \`../local-tools/\` for detailed documentation 660 | - Check individual tool files for JSDoc comments and examples 661 | - Use \`console.log()\` liberally to debug 662 | - MCP tools return objects/strings (not arrays!) - always inspect the response first 663 | - Local tools are synchronous, MCP tools are async (require \`await\`) 664 | 665 | Happy coding! 🎉 666 | `; 667 | } 668 | 669 | /** 670 | * Write all files to the sandbox 671 | */ 672 | export async function writeFilesToSandbox( 673 | sandboxProvider: SandboxProvider, 674 | serverToolsMap: ServerToolsMap, 675 | servers: MCPServerConfig[], 676 | outputDir: string 677 | ): Promise { 678 | const filesToWrite: { path: string; content: Buffer }[] = []; 679 | 680 | // Generate README 681 | const readmeCode = generateREADME(serverToolsMap, servers); 682 | filesToWrite.push({ 683 | path: `${outputDir}/README.md`, 684 | content: Buffer.from(readmeCode, "utf-8"), 685 | }); 686 | 687 | // Generate _client.ts 688 | const clientCode = generateMCPClient(servers); 689 | filesToWrite.push({ 690 | path: `${outputDir}/_client.ts`, 691 | content: Buffer.from(clientCode, "utf-8"), 692 | }); 693 | 694 | // Generate tool files for each server 695 | for (const [serverName, tools] of Object.entries(serverToolsMap)) { 696 | const serverDir = `${outputDir}/${serverName}`; 697 | 698 | // Individual tool files 699 | for (const tool of tools) { 700 | const toolFileName = sanitizeToolName(tool.name); 701 | const toolCode = generateToolFile(tool, serverName); 702 | filesToWrite.push({ 703 | path: `${serverDir}/${toolFileName}.ts`, 704 | content: Buffer.from(toolCode, "utf-8"), 705 | }); 706 | } 707 | 708 | // Server index file 709 | const indexCode = generateServerIndex(tools, serverName); 710 | filesToWrite.push({ 711 | path: `${serverDir}/index.ts`, 712 | content: Buffer.from(indexCode, "utf-8"), 713 | }); 714 | } 715 | 716 | // Write all files at once 717 | await sandboxProvider.writeFiles(filesToWrite); 718 | } 719 | 720 | /** 721 | * Write user-code README to the sandbox 722 | */ 723 | export async function writeUserCodeREADME( 724 | sandboxProvider: SandboxProvider, 725 | userCodeDir: string 726 | ): Promise { 727 | const readmeCode = generateUserCodeREADME(); 728 | await sandboxProvider.writeFiles([ 729 | { 730 | path: `${userCodeDir}/README.md`, 731 | content: Buffer.from(readmeCode, "utf-8"), 732 | }, 733 | ]); 734 | } 735 | --------------------------------------------------------------------------------