From 41d6673f03e84a862f530d7217389763fe9fea10 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:00:10 -0400 Subject: [PATCH 1/9] feat(code): search attached workspaces --- .../services/Endpoints/agents/skillDeps.js | 2 + .../Endpoints/agents/skillDeps.spec.js | 8 ++ api/server/services/Files/Code/process.js | 42 ++++++ .../services/Files/Code/process.spec.js | 45 ++++++ packages/api/src/agents/handlers.spec.ts | 130 ++++++++++++++++++ packages/api/src/agents/handlers.ts | 90 ++++++++++++ packages/api/src/agents/tools.spec.ts | 17 +++ packages/api/src/agents/tools.ts | 37 ++++- packages/api/src/code/workspace.spec.ts | 38 +++++ 9 files changed, 407 insertions(+), 2 deletions(-) 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..6f689baf291 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1710,6 +1710,47 @@ 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] + */ +async function searchWorkspace({ + query, + workspace_id, + path, + max_results, + codeApiBaseUrl, + executionProfile, + bridgeWorkerId, + req, +}) { + 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, + }, + }); +} + /** * 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 +2018,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..a2672419147 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,50 @@ describe('Code Process', () => { }); }); + describe('searchWorkspace', () => { + it('forwards authenticated literal search to the selected attached worker', async () => { + 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, + }), + ).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, + }, + }); + }); + }); + describe('readSandboxFile', () => { /** * `readSandboxFile` shells `cat ` through the sandbox diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index fcf9ff0d4a9..9e7e6b24805 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,134 @@ describe('createToolExecuteHandler', () => { expect(result.errorMessage).not.toContain('/Users/operator/private'); }); + it('searches literal text through the selected attached worker', async () => { + 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 invokeHandler(handler, [ + { + id: 'call_workspace_search', + name: 'search_workspace', + args: { query: 'needle', path: 'src', max_results: 20 }, + }, + ]); + + 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', + }); + expect(result).toMatchObject({ + status: 'success', + content: 'src/app.ts:7:3: const needle = true;', + }); + }); + + 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..02b8c06b3a2 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -23,6 +23,7 @@ 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'; @@ -63,6 +64,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 +457,17 @@ 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; + }) => Promise; /** * Reads a code-execution sandbox file by shelling `cat` through the * sandbox `/exec` endpoint. The host implementation supplies the @@ -2285,6 +2298,75 @@ async function handleWorkspaceFileRead( } } +async function handleWorkspaceSearchCall( + tc: ToolCallRequest, + mergedConfigurable: Record | undefined, + options: ToolExecuteOptions, + req: ServerRequest | undefined, +): 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 } : {}), + }); + + for (const match of result.matches) { + const filtered = filteredFileResult(tc, req, match.path, match.text); + if (filtered != null) return filtered; + } + const content = + result.matches.length === 0 + ? 'No matches found.' + : result.matches + .map((match) => `${match.path}:${match.line}:${match.column}: ${match.text}`) + .join('\n'); + return { + toolCallId: tc.id, + status: 'success', + content: result.truncated ? `${content}\n\n[results truncated]` : content, + }; + } catch (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 +5851,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 +5880,13 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand }, runSignal, ); + } else if (tc.name === SEARCH_WORKSPACE_TOOL_NAME) { + handlerResult = await handleWorkspaceSearchCall( + tc, + mergedConfigurable, + options, + req, + ); } 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({ From 6b90d3ccbb516f6937f3d48e584fa9780145da03 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:04:26 -0400 Subject: [PATCH 2/9] fix(code): remove duplicate workspace type import --- packages/api/src/agents/handlers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 02b8c06b3a2..59902464170 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -25,7 +25,6 @@ import type { } 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'; From 29e393c50b64b70e7e522a8ad23a061c7f43c1d8 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:10:36 -0400 Subject: [PATCH 3/9] fix(code): abort attached workspace searches --- api/server/services/Files/Code/process.js | 3 +++ .../services/Files/Code/process.spec.js | 3 +++ packages/api/src/agents/handlers.spec.ts | 23 +++++++++++++------ packages/api/src/agents/handlers.ts | 5 ++++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 6f689baf291..ae641ceeeee 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1679,6 +1679,7 @@ async function readSandboxFile({ * @param {string} [params.bridgeWorkerId] * @param {ServerRequest} [params.req] * @param {AbortSignal} [params.signal] + * @param {AbortSignal} [params.signal] */ async function readWorkspaceFile({ file_path, @@ -1732,6 +1733,7 @@ async function searchWorkspace({ executionProfile, bridgeWorkerId, req, + signal, }) { const authHeaders = await getCodeApiAuthHeaders(req, bridgeWorkerId); return executeWorkspaceTool({ @@ -1748,6 +1750,7 @@ async function searchWorkspace({ ...(path ? { path } : {}), maxResults: max_results, }, + ...(signal ? { signal } : {}), }); } diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index a2672419147..222acc2b5d5 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -1895,6 +1895,7 @@ 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', @@ -1915,6 +1916,7 @@ describe('Code Process', () => { executionProfile: 'stateful', bridgeWorkerId: 'worker-user-1', req: mockReq, + signal: controller.signal, }), ).resolves.toBe(result); @@ -1933,6 +1935,7 @@ describe('Code Process', () => { path: 'src', maxResults: 20, }, + signal: controller.signal, }); }); }); diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 9e7e6b24805..b8f6ea5a085 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -5028,6 +5028,7 @@ describe('createToolExecuteHandler', () => { }); 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, @@ -5048,13 +5049,20 @@ describe('createToolExecuteHandler', () => { searchWorkspace, }); - const [result] = await invokeHandler(handler, [ - { - id: 'call_workspace_search', - name: 'search_workspace', - args: { query: 'needle', path: 'src', max_results: 20 }, - }, - ]); + 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', @@ -5064,6 +5072,7 @@ describe('createToolExecuteHandler', () => { codeApiBaseUrl: 'https://code.example.com/v1', executionProfile: 'stateful', bridgeWorkerId: 'personal-worker-1', + signal: controller.signal, }); expect(result).toMatchObject({ status: 'success', diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 59902464170..a069de1deae 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -466,6 +466,7 @@ export interface ToolExecuteOptions { executionProfile: CodeExecutionContext['executionProfile']; bridgeWorkerId?: string; req?: ServerRequest; + signal?: AbortSignal; }) => Promise; /** * Reads a code-execution sandbox file by shelling `cat` through the @@ -2302,6 +2303,7 @@ async function handleWorkspaceSearchCall( mergedConfigurable: Record | undefined, options: ToolExecuteOptions, req: ServerRequest | undefined, + signal?: AbortSignal, ): Promise { const codeExecutionContext = getCodeExecutionContext(mergedConfigurable ?? {}); if ( @@ -2340,6 +2342,7 @@ async function handleWorkspaceSearchCall( ? { bridgeWorkerId: codeExecutionContext.bridgeWorkerId } : {}), ...(req ? { req } : {}), + ...(signal ? { signal } : {}), }); for (const match of result.matches) { @@ -2358,6 +2361,7 @@ async function handleWorkspaceSearchCall( content: result.truncated ? `${content}\n\n[results truncated]` : content, }; } catch (error) { + if (signal?.aborted === true && isAbortError(error)) throw error; logger.warn( '[handleWorkspaceSearchCall] Attached workspace search failed', getSafeErrorMetadata(error), @@ -5885,6 +5889,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand mergedConfigurable, options, req, + runSignal, ); } else if (tc.name === CREATE_FILE_TOOL_NAME && isFileAuthoringCall) { handlerResult = await handleCreateFileCall( From acd60fba009f6602c7c74d4eef444e93d27b418b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:24:46 -0400 Subject: [PATCH 4/9] docs(code): correct workspace abort parameters --- api/server/services/Files/Code/process.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index ae641ceeeee..a344d1503f3 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1679,7 +1679,6 @@ async function readSandboxFile({ * @param {string} [params.bridgeWorkerId] * @param {ServerRequest} [params.req] * @param {AbortSignal} [params.signal] - * @param {AbortSignal} [params.signal] */ async function readWorkspaceFile({ file_path, @@ -1723,6 +1722,7 @@ async function readWorkspaceFile({ * @param {'default' | 'stateful'} params.executionProfile * @param {string} [params.bridgeWorkerId] * @param {ServerRequest} [params.req] + * @param {AbortSignal} [params.signal] */ async function searchWorkspace({ query, From 62e10ab8b0de149cd20ca83517382da66168726b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 15:16:04 -0400 Subject: [PATCH 5/9] test(code): include workspace search history --- api/server/services/Endpoints/agents/initialize.spec.js | 1 + 1 file changed, 1 insertion(+) 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( From 51fd09776b02475049ccad7e1b24b40609f7dbd2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 15:30:38 -0400 Subject: [PATCH 6/9] fix(code): keep workspace search foregrounded --- packages/api/src/agents/background.spec.ts | 9 +++++++++ packages/api/src/agents/background.ts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) 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, From 7b84645040737a2176723e96a7dc44c67dbcfdb4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 15:37:16 -0400 Subject: [PATCH 7/9] fix(code): preserve attached search routing --- api/server/services/ToolService.js | 5 ++- .../services/__tests__/ToolService.spec.js | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) 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, From dc92a2fb9f5679d60409b9643504fe5dc9390508 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 09:40:39 -0400 Subject: [PATCH 8/9] fix(code): Bound workspace search output --- packages/api/src/agents/handlers.spec.ts | 40 +++++++++++++++++++++++- packages/api/src/agents/handlers.ts | 19 +++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index b8f6ea5a085..742e066b0c7 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -5076,10 +5076,48 @@ describe('createToolExecuteHandler', () => { }); expect(result).toMatchObject({ status: 'success', - content: 'src/app.ts:7:3: const needle = true;', + 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({ diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index a069de1deae..774bfa13e90 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -2349,16 +2349,29 @@ async function handleWorkspaceSearchCall( const filtered = filteredFileResult(tc, req, match.path, match.text); if (filtered != null) return filtered; } - const content = + const unboundedContent = result.matches.length === 0 ? 'No matches found.' : result.matches - .map((match) => `${match.path}:${match.line}:${match.column}: ${match.text}`) + .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: result.truncated ? `${content}\n\n[results truncated]` : content, + content: truncated ? `${content}${truncationNotice}` : content, }; } catch (error) { if (signal?.aborted === true && isAbortError(error)) throw error; From 3aa40db405a54d714e3e97981afdacee4fa63954 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 10:03:21 -0400 Subject: [PATCH 9/9] style(code): Format workspace search output --- packages/api/src/agents/handlers.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 774bfa13e90..50632e04e99 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -2353,14 +2353,10 @@ async function handleWorkspaceSearchCall( result.matches.length === 0 ? 'No matches found.' : result.matches - .map( - (match) => - `workspace/${match.path}:${match.line}:${match.column}: ${match.text}`, - ) + .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 locallyTruncated = Buffer.byteLength(unboundedContent, 'utf8') > MAX_READABLE_BYTES; const truncated = locallyTruncated || result.truncated; const content = truncated ? truncateUtf8(