UNPKG

@openfiles-ai/sdk

Version:

OpenFiles SDK - AI-native file storage for your AI agents

919 lines 38.5 kB
/** * @openfiles-ai/sdk/tools * * Framework-agnostic tool definitions for AI agents * Provides OpenAI-compatible tool definitions and execution */ import { logger } from '../utils'; // Removed - using internal ToolCall type alias instead /** * OpenFiles Tools for AI Agents * * Provides OpenAI-compatible tool definitions and automatic execution * for file operations. Only handles OpenFiles tools, ignoring others. * * @example * ```typescript * const client = new OpenFilesClient({ apiKey: 'oa_...' }) * const tools = new OpenFilesTools(client) * * // With basePath for organized file structure * const projectTools = new OpenFilesTools(client, 'projects/website') * * // Use with existing OpenAI client * const response = await openai.chat.completions.create({ * model: 'gpt-4', * messages: [...], * tools: [...projectTools.definitions, ...myOtherTools] * }) * * // Process OpenFiles tools only * const processed = await projectTools.processToolCalls(response) * if (processed.handled) { * console.log('Files created:', processed.results) * } * ``` */ export class OpenFilesTools { client; basePath; openai; anthropic; constructor(client, basePath) { this.client = basePath ? client.withBasePath(basePath) : client; this.basePath = basePath; this.openai = new OpenAIProvider(this.client, this.getEffectiveBasePath(client, basePath)); this.anthropic = new AnthropicProvider(this.client, this.getEffectiveBasePath(client, basePath)); } getEffectiveBasePath(client, basePath) { // Get the client's effective basePath (what will actually be used in API calls) const clientBasePath = client.scopedBasePath || client.config?.basePath; if (basePath) { // If we're adding a basePath to an existing client basePath, combine them if (clientBasePath) { return `${clientBasePath}/${basePath}`; } return basePath; } // If no additional basePath, just use the client's basePath return clientBasePath; } /** * Create a new OpenFilesTools instance with a base path prefix * All file operations will automatically prefix paths with the base path * * @param basePath - The base path to prefix to all operations * @returns New OpenFilesTools instance with the specified base path * * @example * ```typescript * const tools = new OpenFilesTools(client) * const projectTools = tools.withBasePath('projects/website') * * // AI operations will create files under 'projects/website/' * ``` */ withBasePath(basePath) { const effectiveClient = this.client.withBasePath(basePath); const effectiveBasePath = this.basePath ? `${this.basePath}/${basePath}` : basePath; return new OpenFilesTools(effectiveClient, effectiveBasePath); } } class OpenAIProvider { client; basePath; constructor(client, basePath) { this.client = client; this.basePath = basePath; } /** * Strip basePath from response to make it transparent to AI */ stripBasePath(result) { if (!this.basePath || !result) return result; // Handle FileMetadata objects if (result.path && typeof result.path === 'string') { if (result.path.startsWith(this.basePath + '/')) { const strippedPath = result.path.slice(this.basePath.length + 1); result = { ...result, path: strippedPath }; } } // Handle file list responses if (result.files && Array.isArray(result.files)) { result = { ...result, files: result.files.map((file) => { if (file.path && typeof file.path === 'string' && this.basePath && file.path.startsWith(this.basePath + '/')) { return { ...file, path: file.path.slice(this.basePath.length + 1) }; } return file; }) }; } // Handle file versions response if (result.versions && Array.isArray(result.versions)) { result = { ...result, versions: result.versions.map((version) => { if (version.path && typeof version.path === 'string' && this.basePath && version.path.startsWith(this.basePath + '/')) { return { ...version, path: version.path.slice(this.basePath.length + 1) }; } return version; }) }; } return result; } /** * OpenAI-compatible tool definitions */ get definitions() { return [ { type: 'function', function: { name: 'write_file', description: 'CREATE a NEW file (fails if file exists). Use when user wants to: create, generate, make, or write a new file. For existing files, use edit_file, append_to_file, or overwrite_file instead.', strict: true, parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)', example: 'document.md' }, content: { type: 'string', description: 'File content to write' }, contentType: { type: 'string', description: 'MIME type of file content. Provide specific type (e.g., text/plain, text/markdown, application/json) or use application/octet-stream as default', default: 'application/octet-stream', example: 'text/markdown' } }, required: ['path', 'content', 'contentType'], additionalProperties: false } } }, { type: 'function', function: { name: 'read_file', description: 'READ and DISPLAY existing file content. Use when user asks to: see, show, read, view, display, or retrieve file content. Returns the actual content to show the user.', strict: true, parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)', example: 'document.md' }, version: { type: 'number', description: 'Specific version to read (use 0 or omit for latest version)', default: 0 } }, required: ['path', 'version'], additionalProperties: false } } }, { type: 'function', function: { name: 'edit_file', description: 'MODIFY parts of an existing file by replacing specific text. Use when user wants to: update, change, fix, or edit specific portions while keeping the rest.', strict: true, parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)', example: 'document.md' }, oldString: { type: 'string', description: 'Exact string to find and replace' }, newString: { type: 'string', description: 'Replacement string' } }, required: ['path', 'oldString', 'newString'], additionalProperties: false } } }, { type: 'function', function: { name: 'list_files', description: 'LIST files. Use when user wants to: browse files, see what exists, explore contents, or find available files. IMPORTANT: Always use recursive=true unless user explicitly asks for a specific directory only.', strict: true, parameters: { type: 'object', properties: { directory: { type: 'string', description: 'Directory path to list files from', example: 'folder/', default: '/' }, recursive: { type: 'boolean', description: 'IMPORTANT: Use true to search all directories (recommended for "list all files"), false only for specific directory browsing', default: true }, limit: { type: 'number', description: 'Maximum number of files to return', default: 10, minimum: 1, maximum: 100 } }, required: ['directory', 'recursive', 'limit'], additionalProperties: false } } }, { type: 'function', function: { name: 'append_to_file', description: 'ADD content to the END of existing file. Use for: adding to logs, extending lists, continuing documents, or accumulating data without losing existing content.', strict: true, parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)', example: 'logs/daily-operations.log' }, content: { type: 'string', description: 'Content to append to the file' } }, required: ['path', 'content'], additionalProperties: false } } }, { type: 'function', function: { name: 'overwrite_file', description: 'REPLACE ALL content in existing file. Use when user wants to: completely rewrite, reset, or replace entire file content. Keeps the file but changes everything inside.', strict: true, parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)', example: 'policies/employee-handbook.md' }, content: { type: 'string', description: 'New content to replace existing content' }, isBase64: { type: 'boolean', description: 'Whether the content is base64 encoded', default: false } }, required: ['path', 'content', 'isBase64'], additionalProperties: false } } }, { type: 'function', function: { name: 'get_file_metadata', description: 'GET file information (size, version, dates) WITHOUT content. Use for: checking file stats, properties, or metadata when content is not needed.', strict: true, parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)', example: 'document.md' }, version: { type: 'number', description: 'Specific version to get metadata for (use 0 for latest version)', default: 0 } }, required: ['path', 'version'], additionalProperties: false } } }, { type: 'function', function: { name: 'get_file_versions', description: 'GET version history of a file. Use when user wants to: see file history, list all versions, or access previous versions.', strict: true, parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)', example: 'document.md' }, limit: { type: 'number', description: 'Maximum number of versions to return', default: 10, minimum: 1, maximum: 100 }, offset: { type: 'number', description: 'Number of versions to skip for pagination', default: 0, minimum: 0 } }, required: ['path', 'limit', 'offset'], additionalProperties: false } } } ]; } /** * Process OpenAI tool calls and execute OpenFiles tools * * Handles ONLY OpenFiles tools, completely ignores others. * This allows you to use OpenFiles alongside other tools. * * Returns tool messages formatted for OpenAI conversation flow. */ // Type guard for function tool calls isFunctionToolCall(toolCall) { return 'function' in toolCall && toolCall.function && typeof toolCall.function.name === 'string'; } async processToolCalls(response) { const results = []; const toolMessages = []; let handled = false; for (const choice of response.choices || []) { const toolCalls = choice.message?.tool_calls || []; for (const toolCall of toolCalls) { // Only process function tool calls if (!this.isFunctionToolCall(toolCall)) continue; if (this.isOpenFilesTool(toolCall.function.name)) { handled = true; try { const args = JSON.parse(toolCall.function.arguments); const result = await this.executeTool(toolCall); // result is already stripped results.push({ toolCallId: toolCall.id, status: 'success', data: result, // stripped result function: toolCall.function.name, args: args }); // Generate tool message for successful execution toolMessages.push({ role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify({ success: true, data: result, // stripped result operation: toolCall.function.name }) }); } catch (error) { const args = JSON.parse(toolCall.function.arguments); const errorMessage = error instanceof Error ? error.message : String(error); results.push({ toolCallId: toolCall.id, status: 'error', error: errorMessage, function: toolCall.function.name, args: args }); // Generate tool message for error toolMessages.push({ role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify({ success: false, error: { code: 'EXECUTION_ERROR', message: errorMessage }, operation: toolCall.function.name }) }); } } // Completely ignore non-OpenFiles tools } } return { handled, results, toolMessages }; } /** * Check if a tool name is an OpenFiles tool (internal method) */ isOpenFilesTool(name) { return [ 'write_file', 'read_file', 'edit_file', 'list_files', 'append_to_file', 'overwrite_file', 'get_file_metadata', 'get_file_versions' ].includes(name); } /** * Execute a single tool call (private - for internal use only) */ async executeTool(toolCall) { const args = JSON.parse(toolCall.function.arguments); let result; switch (toolCall.function.name) { case 'write_file': result = await this.client.writeFile({ path: args.path, content: args.content, contentType: args.contentType }); break; case 'read_file': { const content = await this.client.readFile({ path: args.path, version: args.version === 0 ? undefined : args.version }); return content; // String content doesn't need path stripping } case 'edit_file': result = await this.client.editFile({ path: args.path, oldString: args.oldString, newString: args.newString }); break; case 'list_files': { const listParams = { directory: args.directory, limit: args.limit }; if (args.recursive !== undefined) { listParams.recursive = args.recursive; } logger.debug(`list_files params: ${JSON.stringify(listParams, null, 2)}`); result = await this.client.listFiles(listParams); break; } case 'append_to_file': result = await this.client.appendToFile({ path: args.path, content: args.content }); break; case 'overwrite_file': result = await this.client.overwriteFile({ path: args.path, content: args.content, isBase64: args.isBase64 }); break; case 'get_file_metadata': result = await this.client.getFileMetadata({ path: args.path, version: args.version === 0 ? undefined : args.version }); break; case 'get_file_versions': result = await this.client.getFileVersions({ path: args.path, limit: args.limit, offset: args.offset }); break; default: throw new Error(`Unknown tool: ${toolCall.function.name}`); } // Strip basePath from the result to make it transparent to AI return this.stripBasePath(result); } } class AnthropicProvider { client; basePath; constructor(client, basePath) { this.client = client; this.basePath = basePath; } /** * Strip basePath from response to make it transparent to AI */ stripBasePath(result) { if (!this.basePath || !result) return result; // Handle FileMetadata objects if (result.path && typeof result.path === 'string') { if (result.path.startsWith(this.basePath + '/')) { const strippedPath = result.path.slice(this.basePath.length + 1); result = { ...result, path: strippedPath }; } } // Handle file list responses if (result.files && Array.isArray(result.files)) { result = { ...result, files: result.files.map((file) => { if (file.path && typeof file.path === 'string' && this.basePath && file.path.startsWith(this.basePath + '/')) { return { ...file, path: file.path.slice(this.basePath.length + 1) }; } return file; }) }; } // Handle file versions response if (result.versions && Array.isArray(result.versions)) { result = { ...result, versions: result.versions.map((version) => { if (version.path && typeof version.path === 'string' && this.basePath && version.path.startsWith(this.basePath + '/')) { return { ...version, path: version.path.slice(this.basePath.length + 1) }; } return version; }) }; } return result; } /** * Anthropic-compatible tool definitions */ get definitions() { return [ { name: 'write_file', description: 'CREATE a NEW file (fails if file exists). Use when user wants to: create, generate, make, or write a new file. For existing files, use edit_file, append_to_file, or overwrite_file instead.', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)' }, content: { type: 'string', description: 'File content to write' }, contentType: { type: 'string', description: 'MIME type of file content. Provide specific type (e.g., text/plain, text/markdown, application/json) or use application/octet-stream as default', default: 'application/octet-stream' } }, required: ['path', 'content', 'contentType'] } }, { name: 'read_file', description: 'READ and DISPLAY existing file content. Use when user asks to: see, show, read, view, display, or retrieve file content. Returns the actual content to show the user.', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)' }, version: { type: 'number', description: 'Specific version to read (use 0 or omit for latest version)', default: 0 } }, required: ['path', 'version'] } }, { name: 'edit_file', description: 'MODIFY parts of an existing file by replacing specific text. Use when user wants to: update, change, fix, or edit specific portions while keeping the rest.', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)' }, oldString: { type: 'string', description: 'Exact string to find and replace' }, newString: { type: 'string', description: 'Replacement string' } }, required: ['path', 'oldString', 'newString'] } }, { name: 'list_files', description: 'LIST files. Use when user wants to: browse files, see what exists, explore contents, or find available files. IMPORTANT: Always use recursive=true unless user explicitly asks for a specific directory only.', input_schema: { type: 'object', properties: { directory: { type: 'string', description: 'Directory path to list files from', default: '/' }, recursive: { type: 'boolean', description: 'IMPORTANT: Use true to search all directories (recommended for "list all files"), false only for specific directory browsing', default: true }, limit: { type: 'number', description: 'Maximum number of files to return', default: 10, minimum: 1, maximum: 100 } }, required: ['directory', 'recursive', 'limit'] } }, { name: 'append_to_file', description: 'ADD content to the END of existing file. Use for: adding to logs, extending lists, continuing documents, or accumulating data without losing existing content.', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)' }, content: { type: 'string', description: 'Content to append to the file' } }, required: ['path', 'content'] } }, { name: 'overwrite_file', description: 'REPLACE ALL content in existing file. Use when user wants to: completely rewrite, reset, or replace entire file content. Keeps the file but changes everything inside.', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)' }, content: { type: 'string', description: 'New content to replace existing content' }, isBase64: { type: 'boolean', description: 'Whether the content is base64 encoded', default: false } }, required: ['path', 'content', 'isBase64'] } }, { name: 'get_file_metadata', description: 'GET file information (size, version, dates) WITHOUT content. Use for: checking file stats, properties, or metadata when content is not needed.', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)' }, version: { type: 'number', description: 'Specific version to get metadata for (use 0 for latest version)', default: 0 } }, required: ['path', 'version'] } }, { name: 'get_file_versions', description: 'GET version history of a file. Use when user wants to: see file history, list all versions, or access previous versions.', input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path (S3-style, no leading slash)' }, limit: { type: 'number', description: 'Maximum number of versions to return', default: 10, minimum: 1, maximum: 100 }, offset: { type: 'number', description: 'Number of versions to skip for pagination', default: 0, minimum: 0 } }, required: ['path', 'limit', 'offset'] } } ]; } /** * Process Anthropic tool calls and execute OpenFiles tools */ async processToolCalls(response) { const results = []; const toolMessages = []; let handled = false; // Anthropic format: response.content is an array with tool_use objects const content = response.content || []; const toolResults = []; for (const item of content) { if (item.type === 'tool_use' && item.name && this.isOpenFilesTool(item.name)) { handled = true; try { const result = await this.executeTool({ id: item.id || '', name: item.name, input: item.input || {} }); results.push({ toolCallId: item.id || '', status: 'success', data: result, function: item.name, args: item.input || {} }); toolResults.push({ type: 'tool_result', tool_use_id: item.id || '', content: JSON.stringify({ success: true, data: result, operation: item.name }) }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); results.push({ toolCallId: item.id || '', status: 'error', error: errorMessage, function: item.name || '', args: item.input || {} }); toolResults.push({ type: 'tool_result', tool_use_id: item.id || '', content: JSON.stringify({ success: false, error: { code: 'EXECUTION_ERROR', message: errorMessage }, operation: item.name || '' }) }); } } } if (toolResults.length > 0) { toolMessages.push({ role: 'user', content: toolResults }); } return { handled, results, toolMessages }; } /** * Check if a tool name is an OpenFiles tool */ isOpenFilesTool(name) { return [ 'write_file', 'read_file', 'edit_file', 'list_files', 'append_to_file', 'overwrite_file', 'get_file_metadata', 'get_file_versions' ].includes(name); } /** * Execute a single Anthropic tool call */ async executeTool(toolCall) { const args = toolCall.input; let result; switch (toolCall.name) { case 'write_file': result = await this.client.writeFile({ path: args.path, content: args.content, contentType: args.contentType }); break; case 'read_file': { const content = await this.client.readFile({ path: args.path, version: args.version === 0 ? undefined : args.version }); return content; // String content doesn't need path stripping } case 'edit_file': result = await this.client.editFile({ path: args.path, oldString: args.oldString, newString: args.newString }); break; case 'list_files': const listParams = { directory: args.directory, limit: args.limit }; if (args.recursive !== undefined) { listParams.recursive = args.recursive; } logger.debug(`list_files params: ${JSON.stringify(listParams, null, 2)}`); result = await this.client.listFiles(listParams); break; case 'append_to_file': result = await this.client.appendToFile({ path: args.path, content: args.content }); break; case 'overwrite_file': result = await this.client.overwriteFile({ path: args.path, content: args.content, isBase64: args.isBase64 }); break; case 'get_file_metadata': result = await this.client.getFileMetadata({ path: args.path, version: args.version === 0 ? undefined : args.version }); break; case 'get_file_versions': result = await this.client.getFileVersions({ path: args.path, limit: args.limit, offset: args.offset }); break; default: throw new Error(`Unknown tool: ${toolCall.name}`); } // Strip basePath from the result to make it transparent to AI return this.stripBasePath(result); } } //# sourceMappingURL=tools.js.map