mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 09:32:24 +00:00
🕯️ feat: Search Text in Attached Workspaces (#15525)
* feat(code): search attached workspaces * fix(code): remove duplicate workspace type import * fix(code): abort attached workspace searches * docs(code): correct workspace abort parameters * test(code): include workspace search history * fix(code): keep workspace search foregrounded * fix(code): preserve attached search routing * fix(code): Bound workspace search output * style(code): Format workspace search output
This commit is contained in:
parent
b7fd4ba2a2
commit
9a64e85b1e
14 changed files with 523 additions and 5 deletions
|
|
@ -1052,6 +1052,7 @@ describe('initializeClient — subagent loading', () => {
|
|||
'read_file',
|
||||
'create_file',
|
||||
'edit_file',
|
||||
'search_workspace',
|
||||
]);
|
||||
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <file_path>` through the sandbox
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
|
|
|
|||
|
|
@ -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<ToolExecuteResult[]>((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({
|
||||
|
|
|
|||
|
|
@ -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<WorkspaceReadResult>;
|
||||
/** 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<WorkspaceSearchResult>;
|
||||
/**
|
||||
* 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<string, unknown> | undefined,
|
||||
options: ToolExecuteOptions,
|
||||
req: ServerRequest | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ToolExecuteResult> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string> = new Set([
|
||||
CREATE_FILE_TOOL_NAME,
|
||||
|
|
@ -29,7 +30,11 @@ export function isCodeSessionToolName(
|
|||
name: string,
|
||||
hostFileAuthoringToolNames?: ReadonlySet<string>,
|
||||
): 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 ?? [];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue