Skip to content
1 change: 1 addition & 0 deletions api/server/services/Endpoints/agents/initialize.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,7 @@ describe('initializeClient — subagent loading', () => {
'read_file',
'create_file',
'edit_file',
'search_workspace',
]);

await expect(
Expand Down
2 changes: 2 additions & 0 deletions api/server/services/Endpoints/agents/skillDeps.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
getSessionInfo,
checkIfActive,
readWorkspaceFile,
searchWorkspace,
readSandboxFile,
readSandboxImage,
writeSandboxFile,
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions api/server/services/Endpoints/agents/skillDeps.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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(),
}));

Expand Down Expand Up @@ -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);

Expand Down
45 changes: 45 additions & 0 deletions api/server/services/Files/Code/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1977,6 +2021,7 @@ module.exports = {
processCodeOutput,
prepareCodeOutputForInspection,
readWorkspaceFile,
searchWorkspace,
readSandboxFile,
readSandboxImage,
writeSandboxFile,
Expand Down
48 changes: 48 additions & 0 deletions api/server/services/Files/Code/process.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ const {
getSessionInfo,
readSandboxFile,
readWorkspaceFile,
searchWorkspace,
readSandboxImage,
writeSandboxFile,
primeFiles,
Expand Down Expand Up @@ -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 <file_path>` through the sandbox
Expand Down
5 changes: 4 additions & 1 deletion api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const {
codeExecutionAuthHeaders,
resolveCodeExecutionContext,
resolveCallerCapabilityProjectionSnapshot,
SEARCH_WORKSPACE_TOOL_NAME,
getTransactionsConfig,
} = require('@librechat/api');
const {
Expand Down Expand Up @@ -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),
Expand Down
33 changes: 33 additions & 0 deletions api/server/services/__tests__/ToolService.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions packages/api/src/agents/background.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/agents/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -112,6 +112,7 @@ const EXCLUDED_BACKGROUND_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
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,
Expand Down
Loading
Loading