diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 98cd035d0de..955aac2ab28 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -1052,6 +1052,7 @@ describe('initializeClient — subagent loading', () => { 'read_file', 'create_file', 'edit_file', + 'search_workspace', ]); await expect( diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js index 7b7b6aa219a..1e0a96295d5 100644 --- a/api/server/services/Endpoints/agents/skillDeps.js +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -5,6 +5,7 @@ const { getSessionInfo, checkIfActive, readWorkspaceFile, + searchWorkspace, readSandboxFile, readSandboxImage, writeSandboxFile, @@ -382,6 +383,7 @@ const skillToolDeps = { getSkillFileByPath: deploymentSkillMethods.getSkillFileByPath, updateSkillFileContent: deploymentSkillMethods.updateSkillFileContent, readWorkspaceFile, + searchWorkspace, /** * `read_file` falls back to a sandbox `cat` for `/mnt/data/...` paths * and for `{firstSegment}/...` paths whose first segment isn't a known diff --git a/api/server/services/Endpoints/agents/skillDeps.spec.js b/api/server/services/Endpoints/agents/skillDeps.spec.js index 084e505803e..f9e87b9d224 100644 --- a/api/server/services/Endpoints/agents/skillDeps.spec.js +++ b/api/server/services/Endpoints/agents/skillDeps.spec.js @@ -6,6 +6,7 @@ const mockGetStorageMetadata = jest.fn(); const mockResolveRequestTenantId = jest.fn(); const mockCreateDeploymentSkillMethods = jest.fn((methods) => methods); const mockReadWorkspaceFile = jest.fn(); +const mockSearchWorkspace = jest.fn(); jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args), @@ -20,6 +21,7 @@ jest.mock('~/server/services/Files/Code/process', () => ({ checkIfActive: jest.fn(), readSandboxFile: jest.fn(), readWorkspaceFile: (...args) => mockReadWorkspaceFile(...args), + searchWorkspace: (...args) => mockSearchWorkspace(...args), writeSandboxFile: jest.fn(), })); @@ -87,6 +89,12 @@ describe('skillDeps saveSkillFileContent', () => { expect(mockReadWorkspaceFile).toHaveBeenCalledWith({ file_path: 'src/app.ts' }); }); + it('exposes the stable attached-workspace searcher to agent handlers', () => { + expect(getSkillToolDeps().searchWorkspace).toBeDefined(); + getSkillToolDeps().searchWorkspace({ query: 'needle' }); + expect(mockSearchWorkspace).toHaveBeenCalledWith({ query: 'needle' }); + }); + it('cleans up the uploaded object when metadata upsert returns no row', async () => { mockDb.upsertSkillFile.mockResolvedValue(null); diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 6862840b5a6..a344d1503f3 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1710,6 +1710,50 @@ async function readWorkspaceFile({ }); } +/** + * Searches literal text within the workspace directory registered by an attached worker. + * + * @param {Object} params + * @param {string} params.query + * @param {string} params.workspace_id + * @param {string} [params.path] + * @param {number} params.max_results + * @param {string} params.codeApiBaseUrl + * @param {'default' | 'stateful'} params.executionProfile + * @param {string} [params.bridgeWorkerId] + * @param {ServerRequest} [params.req] + * @param {AbortSignal} [params.signal] + */ +async function searchWorkspace({ + query, + workspace_id, + path, + max_results, + codeApiBaseUrl, + executionProfile, + bridgeWorkerId, + req, + signal, +}) { + const authHeaders = await getCodeApiAuthHeaders(req, bridgeWorkerId); + return executeWorkspaceTool({ + baseURL: codeApiBaseUrl, + authHeaders: { + ...authHeaders, + ...codeExecutionHeaders({ executionProfile, bridgeWorkerId }), + }, + request: { + protocolVersion: 1, + operation: 'search_text', + workspaceId: workspace_id, + query, + ...(path ? { path } : {}), + maxResults: max_results, + }, + ...(signal ? { signal } : {}), + }); +} + /** * Reads a small code artifact as base64 so `read_file` can surface it to * vision-capable models. Reuses bytes fetched by the current request's @@ -1977,6 +2021,7 @@ module.exports = { processCodeOutput, prepareCodeOutputForInspection, readWorkspaceFile, + searchWorkspace, readSandboxFile, readSandboxImage, writeSandboxFile, diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index b6cf887bbab..222acc2b5d5 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -249,6 +249,7 @@ const { getSessionInfo, readSandboxFile, readWorkspaceFile, + searchWorkspace, readSandboxImage, writeSandboxFile, primeFiles, @@ -1892,6 +1893,53 @@ describe('Code Process', () => { }); }); + describe('searchWorkspace', () => { + it('forwards authenticated literal search to the selected attached worker', async () => { + const controller = new AbortController(); + const result = { + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + matches: [], + truncated: false, + }; + getCodeApiAuthHeaders.mockResolvedValueOnce({ Authorization: 'Bearer workspace-token' }); + mockExecuteWorkspaceTool.mockResolvedValueOnce(result); + + await expect( + searchWorkspace({ + query: 'needle', + workspace_id: 'primary', + path: 'src', + max_results: 20, + codeApiBaseUrl: 'https://attached-code.example.com/v1', + executionProfile: 'stateful', + bridgeWorkerId: 'worker-user-1', + req: mockReq, + signal: controller.signal, + }), + ).resolves.toBe(result); + + expect(mockExecuteWorkspaceTool).toHaveBeenCalledWith({ + baseURL: 'https://attached-code.example.com/v1', + authHeaders: { + Authorization: 'Bearer workspace-token', + 'X-CodeAPI-Expected-Profile': 'stateful', + 'X-LibreChat-Code-Worker-ID': 'worker-user-1', + }, + request: { + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + path: 'src', + maxResults: 20, + }, + signal: controller.signal, + }); + }); + }); + describe('readSandboxFile', () => { /** * `readSandboxFile` shells `cat ` through the sandbox diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index c5d4ac0950e..3a0ca2d60d9 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -47,6 +47,7 @@ const { codeExecutionAuthHeaders, resolveCodeExecutionContext, resolveCallerCapabilityProjectionSnapshot, + SEARCH_WORKSPACE_TOOL_NAME, getTransactionsConfig, } = require('@librechat/api'); const { @@ -2000,7 +2001,9 @@ async function loadToolsForExecution({ const isPTCRequested = ptcToolNames.length > 0; const isBashToolRequested = toolNames.includes(AgentConstants.BASH_TOOL); const isLegacyExecuteCodeRequested = toolNames.includes(Tools.execute_code); - const isCodeExecutionToolRequested = isBashToolRequested || isLegacyExecuteCodeRequested; + const isWorkspaceSearchRequested = toolNames.includes(SEARCH_WORKSPACE_TOOL_NAME); + const isCodeExecutionToolRequested = + isBashToolRequested || isLegacyExecuteCodeRequested || isWorkspaceSearchRequested; const isSkillToolRequested = toolNames.includes(AgentConstants.SKILL_TOOL); const isSandboxFileToolRequested = toolNames.some((name) => [AgentConstants.READ_FILE, AgentConstants.CREATE_FILE, AgentConstants.EDIT_FILE].includes(name), diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index fe7506252a7..d9a2169795c 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -2565,6 +2565,39 @@ describe('ToolService - Action Capability Gating', () => { } }); + it('preserves attached routing when workspace search is the only requested tool', async () => { + const capabilities = [ + AgentCapabilities.tools, + AgentCapabilities.execute_code, + AgentCapabilities.stateful_code_sessions, + ]; + const req = createMockReq(capabilities); + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'http://code-stateful.test/v1'; + + try { + const result = await loadToolsForExecution({ + req, + res: {}, + agent: { + id: 'stateful-agent', + tools: [Tools.execute_code], + stateful_code_sessions: true, + stateful_code_environment: 'agent-user', + }, + toolNames: ['search_workspace'], + actionsEnabled: false, + }); + + expect(result.configurable.codeExecutionContext.executionProfile).toBe('stateful'); + expect(mockResolveCodeExecutionContext).toHaveBeenLastCalledWith( + expect.objectContaining({ statefulSessions: true, environment: 'agent-user' }), + ); + } finally { + delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL; + } + }); + it('resolves stateful routing when handle_skill is the only requested tool', async () => { const capabilities = [ AgentCapabilities.tools, diff --git a/packages/api/src/agents/background.spec.ts b/packages/api/src/agents/background.spec.ts index 1a0fc9460c4..1939ed40c7f 100644 --- a/packages/api/src/agents/background.spec.ts +++ b/packages/api/src/agents/background.spec.ts @@ -507,6 +507,15 @@ describe('selection policy at injection time', () => { expect(backgroundToolNames).toEqual(['search_mcp_overlay_server']); }); + it('keeps host-side workspace search in the foreground', () => { + const { backgroundToolNames } = applyBackgroundToolCalls({ + toolDefinitions: [mcpDef('search_workspace')], + toolRegistry: undefined, + toolOptions: { search_workspace: { run_in_background: true } }, + }); + expect(backgroundToolNames).toEqual([]); + }); + it('rejects and diagnoses a marker whose runtime definitions are all excluded', () => { /** `runInBackground: ['memory']` used to record a successful-looking * option under the marker while set_memory/delete_memory — the diff --git a/packages/api/src/agents/background.ts b/packages/api/src/agents/background.ts index 776cc53ad08..f0d25f4bf57 100644 --- a/packages/api/src/agents/background.ts +++ b/packages/api/src/agents/background.ts @@ -62,10 +62,10 @@ import { synthesizeSelectionToolOptions, } from './selection'; import { SUBAGENT_WAKEUP_GUIDANCE, agentUsesSubagentCompletionWakeups } from './subagentDelivery'; +import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME, SEARCH_WORKSPACE_TOOL_NAME } from './tools'; import { SubagentTaskOwnerUnavailableError } from './subagentTaskRouting'; import { SET_MEMORY_TOOL_NAME, DELETE_MEMORY_TOOL_NAME } from './memory'; import { ASK_USER_QUESTION_TOOL_NAME } from './hitl/askUserQuestionTool'; -import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from './tools'; import { normalizeActionToolName } from '~/actions/tools'; import { truncateMiddle } from '~/utils'; @@ -112,6 +112,7 @@ const EXCLUDED_BACKGROUND_TOOL_NAMES: ReadonlySet = new Set([ AgentConstants.SUBAGENT, CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME, + SEARCH_WORKSPACE_TOOL_NAME, SET_MEMORY_TOOL_NAME, DELETE_MEMORY_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index fcf9ff0d4a9..742e066b0c7 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -4694,6 +4694,7 @@ describe('createToolExecuteHandler', () => { codeExecutionContext?: CodeExecutionContext; req?: unknown; readWorkspaceFile?: ToolExecuteOptions['readWorkspaceFile']; + searchWorkspace?: ToolExecuteOptions['searchWorkspace']; readSandboxFile?: ToolExecuteOptions['readSandboxFile']; readSandboxImage?: ToolExecuteOptions['readSandboxImage']; getSkillByName?: ToolExecuteOptions['getSkillByName']; @@ -4716,6 +4717,7 @@ describe('createToolExecuteHandler', () => { getSkillByName: params.getSkillByName, getAuthorSkillByName: params.getAuthorSkillByName, readWorkspaceFile: params.readWorkspaceFile, + searchWorkspace: params.searchWorkspace, readSandboxFile: params.readSandboxFile, readSandboxImage: params.readSandboxImage, }); @@ -5025,6 +5027,181 @@ describe('createToolExecuteHandler', () => { expect(result.errorMessage).not.toContain('/Users/operator/private'); }); + it('searches literal text through the selected attached worker', async () => { + const controller = new AbortController(); + const searchWorkspace = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'search_text' as const, + workspaceId: 'primary', + matches: [{ path: 'src/app.ts', line: 7, column: 3, text: 'const needle = true;' }], + truncated: false, + })); + const handler = makeReadFileHandler({ + codeEnvAvailable: true, + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + environmentType: 'attached', + bridgeWorkerId: 'personal-worker-1', + statefulSessions: true, + }, + searchWorkspace, + }); + + const [result] = await new Promise((resolve, reject) => { + handler.handle('on_tool_execute', { + toolCalls: [ + { + id: 'call_workspace_search', + name: 'search_workspace', + args: { query: 'needle', path: 'src', max_results: 20 }, + }, + ], + signal: controller.signal, + resolve, + reject, + } as ToolExecuteBatchRequest); + }); + + expect(searchWorkspace).toHaveBeenCalledWith({ + query: 'needle', + workspace_id: 'primary', + path: 'src', + max_results: 20, + codeApiBaseUrl: 'https://code.example.com/v1', + executionProfile: 'stateful', + bridgeWorkerId: 'personal-worker-1', + signal: controller.signal, + }); + expect(result).toMatchObject({ + status: 'success', + content: 'workspace/src/app.ts:7:3: const needle = true;', + }); + }); + + it('bounds workspace search output in UTF-8 bytes', async () => { + const searchWorkspace = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'search_text' as const, + workspaceId: 'primary', + matches: Array.from({ length: 200 }, (_, index) => ({ + path: `src/result-${index}.txt`, + line: index + 1, + column: 1, + text: '界'.repeat(600), + })), + truncated: false, + })); + const handler = makeReadFileHandler({ + codeEnvAvailable: true, + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + environmentType: 'attached', + statefulSessions: true, + }, + searchWorkspace, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_large_workspace_search', + name: 'search_workspace', + args: { query: '界', max_results: 200 }, + }, + ]); + + expect(result.status).toBe('success'); + expect(result.content).toContain('[results truncated]'); + expect(Buffer.byteLength(result.content as string, 'utf8')).toBeLessThanOrEqual(262_144); + }); + + it('rejects workspace search outside attached environments', async () => { + const searchWorkspace = jest.fn(); + const handler = makeReadFileHandler({ + codeEnvAvailable: true, + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + codeSessionKey: 'execute_code:stateful:managed', + executionProfile: 'stateful', + environmentType: 'managed', + statefulSessions: true, + }, + searchWorkspace, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_managed_workspace_search', + name: 'search_workspace', + args: { query: 'needle' }, + }, + ]); + + expect(result.errorMessage).toContain('requires an attached code environment'); + expect(searchWorkspace).not.toHaveBeenCalled(); + }); + + it('filters every workspace search match before returning any result', async () => { + const protectedValue = 'PROTECTED-WORKSPACE-MATCH'; + const searchWorkspace = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'search_text' as const, + workspaceId: 'primary', + matches: [ + { path: 'safe.txt', line: 1, column: 1, text: 'safe match' }, + { path: 'secret.txt', line: 2, column: 1, text: protectedValue }, + ], + truncated: false, + })); + const handler = makeReadFileHandler({ + req: { + user: { id: 'user-1' }, + config: { + filters: { + files: { + pii: { + fields: ['content'], + starterPatterns: [], + customPatterns: [ + { + id: 'protected-workspace-match', + label: 'protected workspace match', + regex: protectedValue, + }, + ], + }, + }, + }, + }, + }, + codeEnvAvailable: true, + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + environmentType: 'attached', + statefulSessions: true, + }, + searchWorkspace, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_filtered_workspace_search', + name: 'search_workspace', + args: { query: 'match' }, + }, + ]); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toContain('content_filter_block'); + expect(result.errorMessage).not.toContain(protectedValue); + expect(result.content).toBe(''); + }); + it('routes /mnt/data/ paths to the sandbox fallback when codeEnv is available', async () => { const readSandboxFile = jest.fn(async () => ({ content: 'hello-world' })); const handler = makeReadFileHandler({ diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index abfadfe0561..50632e04e99 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -23,8 +23,8 @@ import type { BackgroundToolWakeupAdmission, BackgroundToolWakeupRegistration, } from './backgroundCompletion'; +import type { WorkspaceReadResult, WorkspaceSearchResult } from '~/code/workspace'; import type { SkillFileRecord, PrimeSkillFilesResult } from './skillFiles'; -import type { WorkspaceReadResult } from '~/code/workspace'; import type { BackgroundToolResultState } from './harvest'; import type { CodeExecutionContext } from './execution'; import type { TextContentFragment } from '~/protection'; @@ -63,6 +63,7 @@ import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME, HOST_FILE_AUTHORING_ARTIFACT_KEY, + SEARCH_WORKSPACE_TOOL_NAME, isCodeSessionToolName, } from './tools'; import { @@ -455,6 +456,18 @@ export interface ToolExecuteOptions { req?: ServerRequest; signal?: AbortSignal; }) => Promise; + /** Searches literal text within an attached worker's logical workspace. */ + searchWorkspace?: (params: { + query: string; + workspace_id: string; + path?: string; + max_results: number; + codeApiBaseUrl: string; + executionProfile: CodeExecutionContext['executionProfile']; + bridgeWorkerId?: string; + req?: ServerRequest; + signal?: AbortSignal; + }) => Promise; /** * Reads a code-execution sandbox file by shelling `cat` through the * sandbox `/exec` endpoint. The host implementation supplies the @@ -2285,6 +2298,87 @@ async function handleWorkspaceFileRead( } } +async function handleWorkspaceSearchCall( + tc: ToolCallRequest, + mergedConfigurable: Record | undefined, + options: ToolExecuteOptions, + req: ServerRequest | undefined, + signal?: AbortSignal, +): Promise { + const codeExecutionContext = getCodeExecutionContext(mergedConfigurable ?? {}); + if ( + mergedConfigurable?.codeEnvAvailable !== true || + codeExecutionContext?.environmentType !== 'attached' + ) { + return errorResult(tc, 'search_workspace requires an attached code environment.'); + } + if (!options.searchWorkspace) { + return errorResult(tc, 'Attached workspace search is not configured.'); + } + + const args = tc.args as { query?: unknown; path?: unknown; max_results?: unknown }; + const maxResults = args.max_results ?? 50; + if ( + typeof args.query !== 'string' || + args.query.length === 0 || + args.query.length > 4096 || + (args.path != null && typeof args.path !== 'string') || + !Number.isSafeInteger(maxResults) || + Number(maxResults) < 1 || + Number(maxResults) > 200 + ) { + return errorResult(tc, 'query, path, or max_results is invalid for workspace search.'); + } + + try { + const result = await options.searchWorkspace({ + query: args.query, + workspace_id: 'primary', + ...(typeof args.path === 'string' && args.path.length > 0 ? { path: args.path } : {}), + max_results: Number(maxResults), + codeApiBaseUrl: codeExecutionContext.baseUrl, + executionProfile: codeExecutionContext.executionProfile, + ...(codeExecutionContext.bridgeWorkerId + ? { bridgeWorkerId: codeExecutionContext.bridgeWorkerId } + : {}), + ...(req ? { req } : {}), + ...(signal ? { signal } : {}), + }); + + for (const match of result.matches) { + const filtered = filteredFileResult(tc, req, match.path, match.text); + if (filtered != null) return filtered; + } + const unboundedContent = + result.matches.length === 0 + ? 'No matches found.' + : result.matches + .map((match) => `workspace/${match.path}:${match.line}:${match.column}: ${match.text}`) + .join('\n'); + const truncationNotice = '\n\n[results truncated]'; + const locallyTruncated = Buffer.byteLength(unboundedContent, 'utf8') > MAX_READABLE_BYTES; + const truncated = locallyTruncated || result.truncated; + const content = truncated + ? truncateUtf8( + unboundedContent, + MAX_READABLE_BYTES - Buffer.byteLength(truncationNotice, 'utf8'), + ) + : unboundedContent; + return { + toolCallId: tc.id, + status: 'success', + content: truncated ? `${content}${truncationNotice}` : content, + }; + } catch (error) { + if (signal?.aborted === true && isAbortError(error)) throw error; + logger.warn( + '[handleWorkspaceSearchCall] Attached workspace search failed', + getSafeErrorMetadata(error), + ); + return errorResult(tc, 'The attached workspace could not be searched.'); + } +} + function sandboxSessionContext( tc: ToolCallRequest, override?: SandboxSessionContext, @@ -5769,6 +5863,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand if ( tc.name === Constants.SKILL_TOOL || tc.name === Constants.READ_FILE || + tc.name === SEARCH_WORKSPACE_TOOL_NAME || isFileAuthoringCall ) { const req = mergedConfigurable?.req as ServerRequest | undefined; @@ -5797,6 +5892,14 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand }, runSignal, ); + } else if (tc.name === SEARCH_WORKSPACE_TOOL_NAME) { + handlerResult = await handleWorkspaceSearchCall( + tc, + mergedConfigurable, + options, + req, + runSignal, + ); } else if (tc.name === CREATE_FILE_TOOL_NAME && isFileAuthoringCall) { handlerResult = await handleCreateFileCall( tc, diff --git a/packages/api/src/agents/tools.spec.ts b/packages/api/src/agents/tools.spec.ts index 6b10f044fdd..962f8dfc5df 100644 --- a/packages/api/src/agents/tools.spec.ts +++ b/packages/api/src/agents/tools.spec.ts @@ -313,6 +313,7 @@ describe('buildHistoricalToolNames', () => { 'read_file', 'create_file', 'edit_file', + 'search_workspace', 'set_memory', 'delete_memory', 'skill', @@ -484,6 +485,9 @@ describe('registerCodeExecutionTools', () => { }); const readFile = result.toolDefinitions.find((definition) => definition.name === 'read_file'); + const searchWorkspace = result.toolDefinitions.find( + (definition) => definition.name === 'search_workspace', + ); expect(readFile?.description).toContain('workspace/'); expect(readFile?.description).toContain('attached'); expect(readFile?.parameters).toMatchObject({ @@ -492,6 +496,18 @@ describe('registerCodeExecutionTools', () => { max_lines: { type: 'integer', maximum: 500 }, }, }); + expect(searchWorkspace).toMatchObject({ + name: 'search_workspace', + parameters: { + properties: { + query: { type: 'string' }, + path: { type: 'string' }, + max_results: { type: 'integer', maximum: 200 }, + }, + required: ['query'], + }, + }); + expect(searchWorkspace?.description).toContain('literal text'); }); it('upgrades a code-only read_file definition when skills are enabled later in the run', () => { @@ -753,6 +769,7 @@ describe('registerFileAuthoringTools', () => { it('recognizes host-side file authoring tools as code-session-aware without mutating the shared set', () => { expect(isCodeSessionToolName('bash_tool')).toBe(true); + expect(isCodeSessionToolName('search_workspace')).toBe(true); expect(isCodeSessionToolName('create_file')).toBe(false); expect(isCodeSessionToolName('edit_file')).toBe(false); expect(isCodeSessionToolName('create_file', FILE_AUTHORING_TOOL_NAMES)).toBe(true); diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index 84fad0a1188..5f34571d249 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -19,6 +19,7 @@ import { collectReachableAgents } from './traversal'; export const CREATE_FILE_TOOL_NAME = 'create_file'; export const EDIT_FILE_TOOL_NAME = 'edit_file'; +export const SEARCH_WORKSPACE_TOOL_NAME = 'search_workspace'; export const HOST_FILE_AUTHORING_ARTIFACT_KEY = '__librechat_file_authoring'; export const FILE_AUTHORING_TOOL_NAMES: ReadonlySet = new Set([ CREATE_FILE_TOOL_NAME, @@ -29,7 +30,11 @@ export function isCodeSessionToolName( name: string, hostFileAuthoringToolNames?: ReadonlySet, ): boolean { - return CODE_EXECUTION_TOOLS.has(name) || hostFileAuthoringToolNames?.has(name) === true; + return ( + CODE_EXECUTION_TOOLS.has(name) || + name === SEARCH_WORKSPACE_TOOL_NAME || + hostFileAuthoringToolNames?.has(name) === true + ); } interface ToolDefLike { @@ -97,6 +102,7 @@ export function buildHistoricalToolNames(config: BuildHistoricalToolNamesConfig) toolNames.add('read_file'); toolNames.add(CREATE_FILE_TOOL_NAME); toolNames.add(EDIT_FILE_TOOL_NAME); + toolNames.add(SEARCH_WORKSPACE_TOOL_NAME); } if (config.memoryAvailable === true) { toolNames.add('set_memory'); @@ -470,6 +476,32 @@ function createAttachedWorkspaceReadFileDef(includeSkillFileInstructions: boolea const ATTACHED_CODE_READ_FILE_DEF = createAttachedWorkspaceReadFileDef(false); const ATTACHED_SKILL_READ_FILE_DEF = createAttachedWorkspaceReadFileDef(true); +const SEARCH_WORKSPACE_TOOL_DEF: LCTool = Object.freeze({ + name: SEARCH_WORKSPACE_TOOL_NAME, + description: + 'Search for literal text within the attached worker workspace directory. Git is not required. Respects normal ignore files, does not follow symlinks, and returns bounded path, line, column, and text matches. Use path to limit the search to a relative file or directory.', + parameters: Object.freeze({ + type: 'object', + properties: { + query: { + type: 'string', + description: 'Non-empty literal text to find. This is not a regular expression.', + }, + path: { + type: 'string', + description: 'Optional relative file or directory within the attached workspace.', + }, + max_results: { + type: 'integer', + minimum: 1, + maximum: 200, + description: 'Optional maximum number of matches to return. Defaults to 50.', + }, + }, + required: ['query'], + }) as LCTool['parameters'], +}) as LCTool; + const SKILL_CREATE_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ type: 'object', properties: { @@ -759,9 +791,10 @@ export function registerCodeExecutionTools( } = params; const readFileDef = buildReadFileDef(includeSkillFileInstructions, workspaceTools); - const candidates: LCTool[] = includeBash + const codeTools: LCTool[] = includeBash ? [readFileDef, buildBashToolDef({ enableToolOutputReferences, statefulSessions })] : [readFileDef]; + const candidates = workspaceTools ? [...codeTools, SEARCH_WORKSPACE_TOOL_DEF] : codeTools; const toolNames = candidates.map((def) => def.name); const inputDefinitions = toolDefinitions ?? []; diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index b2806747be5..1ebadb19c25 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -300,6 +300,44 @@ describe('executeWorkspaceTool', () => { expect(cancel).toHaveBeenCalledTimes(1); }); + test('validates bounded search matches before returning them', async () => { + const fetchImpl = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + matches: [ + { + path: 'src/app.ts', + line: 7, + column: 3, + text: 'const needle = true;', + hostRoot: '/Users/operator/private', + }, + ], + truncated: false, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + maxResults: 20, + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + }); + test('rejects an oversized response before parsing worker-controlled JSON', async () => { const cancel = jest.fn(); const json = jest.fn().mockResolvedValue({