@probelabs/probe
Version:
Node.js wrapper for the probe code search tool
401 lines (342 loc) • 13.8 kB
JavaScript
// Simplified tool wrapper for probe agent (based on examples/chat/probeTool.js)
import { listFilesByLevel } from '../index.js';
import { exec } from 'child_process';
import { promisify } from 'util';
import { randomUUID } from 'crypto';
import { EventEmitter } from 'events';
import fs from 'fs';
import { promises as fsPromises } from 'fs';
import path from 'path';
import { glob } from 'glob';
// Create an event emitter for tool calls (simplified for single-shot operations)
export const toolCallEmitter = new EventEmitter();
// Map to track active tool executions by session ID
const activeToolExecutions = new Map();
// Function to check if a session has been cancelled
export function isSessionCancelled(sessionId) {
return activeToolExecutions.get(sessionId)?.cancelled || false;
}
// Function to cancel all tool executions for a session
export function cancelToolExecutions(sessionId) {
if (process.env.DEBUG === '1') {
console.log(`Cancelling tool executions for session: ${sessionId}`);
}
const sessionData = activeToolExecutions.get(sessionId);
if (sessionData) {
sessionData.cancelled = true;
return true;
}
return false;
}
// Function to register a new tool execution
function registerToolExecution(sessionId) {
if (!sessionId) return;
if (!activeToolExecutions.has(sessionId)) {
activeToolExecutions.set(sessionId, { cancelled: false });
} else {
// Reset cancelled flag if session already exists for a new execution
activeToolExecutions.get(sessionId).cancelled = false;
}
}
// Function to clear tool execution data for a session
export function clearToolExecutionData(sessionId) {
if (!sessionId) return;
if (activeToolExecutions.has(sessionId)) {
activeToolExecutions.delete(sessionId);
if (process.env.DEBUG === '1') {
console.log(`Cleared tool execution data for session: ${sessionId}`);
}
}
}
// Wrap the tools to emit events and handle cancellation
const wrapToolWithEmitter = (tool, toolName, baseExecute) => {
return {
...tool, // Spread schema, description etc.
execute: async (params) => { // The execute function now receives parsed params
const debug = process.env.DEBUG === '1';
// Get the session ID from params (passed down from ProbeAgent)
const toolSessionId = params.sessionId || randomUUID();
if (debug) {
console.log(`[DEBUG] probeTool: Executing ${toolName} for session ${toolSessionId}`);
}
registerToolExecution(toolSessionId);
let executionError = null;
let result = null;
try {
// Emit the tool call start event
const toolCallStartData = {
timestamp: new Date().toISOString(),
name: toolName,
args: params,
status: 'started'
};
if (debug) {
console.log(`[DEBUG] probeTool: Emitting toolCallStart:${toolSessionId}`);
}
toolCallEmitter.emit(`toolCall:${toolSessionId}`, toolCallStartData);
// Check for cancellation before execution
if (isSessionCancelled(toolSessionId)) {
if (debug) {
console.log(`Tool execution cancelled before start for ${toolSessionId}`);
}
throw new Error(`Tool execution cancelled for session ${toolSessionId}`);
}
// Execute the base function
result = await baseExecute(params);
// Check for cancellation after execution
if (isSessionCancelled(toolSessionId)) {
if (debug) {
console.log(`Tool execution cancelled after completion for ${toolSessionId}`);
}
throw new Error(`Tool execution cancelled for session ${toolSessionId}`);
}
} catch (error) {
executionError = error;
if (debug) {
console.error(`[DEBUG] probeTool: Error in ${toolName}:`, error);
}
}
// Handle execution results and emit appropriate events
if (executionError) {
const toolCallErrorData = {
timestamp: new Date().toISOString(),
name: toolName,
args: params,
error: executionError.message || 'Unknown error',
status: 'error'
};
if (debug) {
console.log(`[DEBUG] probeTool: Emitting toolCall:${toolSessionId} (error)`);
}
toolCallEmitter.emit(`toolCall:${toolSessionId}`, toolCallErrorData);
throw executionError;
} else {
// If loop exited due to cancellation within the loop
if (isSessionCancelled(toolSessionId)) {
if (process.env.DEBUG === '1') {
console.log(`Tool execution finished but session was cancelled for ${toolSessionId}`);
}
throw new Error(`Tool execution cancelled for session ${toolSessionId}`);
}
// Emit the tool call completion event
const toolCallData = {
timestamp: new Date().toISOString(),
name: toolName,
args: params,
// Safely preview result
resultPreview: typeof result === 'string'
? (result.length > 200 ? result.substring(0, 200) + '...' : result)
: (result ? JSON.stringify(result).substring(0, 200) + '...' : 'No Result'),
status: 'completed'
};
if (debug) {
console.log(`[DEBUG] probeTool: Emitting toolCall:${toolSessionId} (completed)`);
}
toolCallEmitter.emit(`toolCall:${toolSessionId}`, toolCallData);
return result;
}
}
};
};
// Create wrapped tool instances - these will be created by the ProbeAgent
export function createWrappedTools(baseTools) {
const wrappedTools = {};
// Wrap search tool
if (baseTools.searchTool) {
wrappedTools.searchToolInstance = wrapToolWithEmitter(
baseTools.searchTool,
'search',
baseTools.searchTool.execute
);
}
// Wrap query tool
if (baseTools.queryTool) {
wrappedTools.queryToolInstance = wrapToolWithEmitter(
baseTools.queryTool,
'query',
baseTools.queryTool.execute
);
}
// Wrap extract tool
if (baseTools.extractTool) {
wrappedTools.extractToolInstance = wrapToolWithEmitter(
baseTools.extractTool,
'extract',
baseTools.extractTool.execute
);
}
// Wrap delegate tool
if (baseTools.delegateTool) {
wrappedTools.delegateToolInstance = wrapToolWithEmitter(
baseTools.delegateTool,
'delegate',
baseTools.delegateTool.execute
);
}
// Wrap bash tool
if (baseTools.bashTool) {
wrappedTools.bashToolInstance = wrapToolWithEmitter(
baseTools.bashTool,
'bash',
baseTools.bashTool.execute
);
}
return wrappedTools;
}
// Simple file listing tool with ls-like formatted output
export const listFilesTool = {
execute: async (params) => {
const { directory = '.', workingDirectory } = params;
// Use the provided working directory, or fall back to process.cwd()
const baseCwd = workingDirectory || process.cwd();
// Security: Validate path to prevent traversal attacks
const secureBaseDir = path.resolve(baseCwd);
// Check if this is a dependency path that should bypass workspace restrictions
const isDependencyPath = directory.startsWith('/dep/') ||
directory.startsWith('go:') ||
directory.startsWith('js:') ||
directory.startsWith('rust:');
// If directory is absolute, check if it's within the secure base directory
// If it's relative, resolve it against the secure base directory
let targetDir;
if (path.isAbsolute(directory)) {
targetDir = path.resolve(directory);
// Check if the absolute path is within the secure base directory
// Allow dependency paths to bypass this restriction
if (!isDependencyPath && !targetDir.startsWith(secureBaseDir + path.sep) && targetDir !== secureBaseDir) {
throw new Error(`Path traversal attempt detected. Cannot access directory outside workspace: ${directory}`);
}
} else {
targetDir = path.resolve(secureBaseDir, directory);
// Double-check the resolved path is still within the secure base directory
// Allow dependency paths to bypass this restriction
if (!isDependencyPath && !targetDir.startsWith(secureBaseDir + path.sep) && targetDir !== secureBaseDir) {
throw new Error(`Path traversal attempt detected. Access denied: ${directory}`);
}
}
const debug = process.env.DEBUG === '1';
if (debug) {
console.log(`[DEBUG] Listing files in directory: ${targetDir}`);
}
try {
// Read the directory contents
const files = await fsPromises.readdir(targetDir, { withFileTypes: true });
// Format size for human readability
const formatSize = (size) => {
if (size < 1024) return `${size}B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}K`;
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)}M`;
return `${(size / (1024 * 1024 * 1024)).toFixed(1)}G`;
};
// Format the results as ls-style output
const entries = await Promise.all(files.map(async (file) => {
const isDirectory = file.isDirectory();
const fullPath = path.join(targetDir, file.name);
let size = 0;
try {
const stats = await fsPromises.stat(fullPath);
size = stats.size;
} catch (statError) {
if (debug) {
console.log(`[DEBUG] Could not stat file ${file.name}:`, statError.message);
}
}
return {
name: file.name,
isDirectory,
size
};
}));
// Sort: directories first, then files, both alphabetically
entries.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
// Format entries
const formatted = entries.map(entry => {
const type = entry.isDirectory ? 'dir ' : 'file';
const sizeStr = formatSize(entry.size).padStart(8);
return `${type} ${sizeStr} ${entry.name}`;
});
if (debug) {
console.log(`[DEBUG] Found ${entries.length} files/directories in ${targetDir}`);
}
// Return as formatted text output
const header = `${targetDir}:\n`;
const output = header + formatted.join('\n');
return output;
} catch (error) {
throw new Error(`Failed to list files: ${error.message}`);
}
}
};
// Simple file search tool with timeout protection
export const searchFilesTool = {
execute: async (params) => {
const { pattern, directory = '.', recursive = true, workingDirectory } = params;
if (!pattern) {
throw new Error('Pattern is required for file search');
}
// Security: Validate path to prevent traversal attacks
const baseCwd = workingDirectory || process.cwd();
const secureBaseDir = path.resolve(baseCwd);
// Check if this is a dependency path that should bypass workspace restrictions
const isDependencyPath = directory.startsWith('/dep/') ||
directory.startsWith('go:') ||
directory.startsWith('js:') ||
directory.startsWith('rust:');
// If directory is absolute, check if it's within the secure base directory
// If it's relative, resolve it against the secure base directory
let targetDir;
if (path.isAbsolute(directory)) {
targetDir = path.resolve(directory);
// Check if the absolute path is within the secure base directory
// Allow dependency paths to bypass this restriction
if (!isDependencyPath && !targetDir.startsWith(secureBaseDir + path.sep) && targetDir !== secureBaseDir) {
throw new Error(`Path traversal attempt detected. Cannot access directory outside workspace: ${directory}`);
}
} else {
targetDir = path.resolve(secureBaseDir, directory);
// Double-check the resolved path is still within the secure base directory
// Allow dependency paths to bypass this restriction
if (!isDependencyPath && !targetDir.startsWith(secureBaseDir + path.sep) && targetDir !== secureBaseDir) {
throw new Error(`Path traversal attempt detected. Access denied: ${directory}`);
}
}
// Validate pattern complexity to prevent DoS
if (pattern.includes('**/**') || pattern.split('*').length > 10) {
throw new Error('Pattern too complex. Please use a simpler glob pattern.');
}
try {
const options = {
cwd: targetDir,
ignore: ['node_modules/**', '.git/**'],
absolute: false
};
if (!recursive) {
options.deep = 1;
}
// Create a timeout promise (10 seconds)
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Search operation timed out after 10 seconds')), 10000);
});
// Race glob against timeout
const files = await Promise.race([
glob(pattern, options),
timeoutPromise
]);
// Limit results to prevent memory issues
const maxResults = 1000;
if (files.length > maxResults) {
return files.slice(0, maxResults);
}
return files;
} catch (error) {
throw new Error(`Failed to search files: ${error.message}`);
}
}
};
// Wrap the additional tools
export const listFilesToolInstance = wrapToolWithEmitter(listFilesTool, 'listFiles', listFilesTool.execute);
export const searchFilesToolInstance = wrapToolWithEmitter(searchFilesTool, 'searchFiles', searchFilesTool.execute);