Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -1053,6 +1053,7 @@ describe('initializeClient — subagent loading', () => {
'create_file',
'edit_file',
'search_workspace',
'list_workspace_files',
]);

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 @@ -6,6 +6,7 @@ const {
checkIfActive,
readWorkspaceFile,
searchWorkspace,
listWorkspaceFiles,
readSandboxFile,
readSandboxImage,
writeSandboxFile,
Expand Down Expand Up @@ -384,6 +385,7 @@ const skillToolDeps = {
updateSkillFileContent: deploymentSkillMethods.updateSkillFileContent,
readWorkspaceFile,
searchWorkspace,
listWorkspaceFiles,
/**
* `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 @@ -7,6 +7,7 @@ const mockResolveRequestTenantId = jest.fn();
const mockCreateDeploymentSkillMethods = jest.fn((methods) => methods);
const mockReadWorkspaceFile = jest.fn();
const mockSearchWorkspace = jest.fn();
const mockListWorkspaceFiles = jest.fn();

jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args),
Expand All @@ -22,6 +23,7 @@ jest.mock('~/server/services/Files/Code/process', () => ({
readSandboxFile: jest.fn(),
readWorkspaceFile: (...args) => mockReadWorkspaceFile(...args),
searchWorkspace: (...args) => mockSearchWorkspace(...args),
listWorkspaceFiles: (...args) => mockListWorkspaceFiles(...args),
writeSandboxFile: jest.fn(),
}));

Expand Down Expand Up @@ -95,6 +97,12 @@ describe('skillDeps saveSkillFileContent', () => {
expect(mockSearchWorkspace).toHaveBeenCalledWith({ query: 'needle' });
});

it('exposes the stable attached-workspace file lister to agent handlers', () => {
expect(getSkillToolDeps().listWorkspaceFiles).toBeDefined();
getSkillToolDeps().listWorkspaceFiles({ path: 'src' });
expect(mockListWorkspaceFiles).toHaveBeenCalledWith({ path: 'src' });
});

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 @@ -1754,6 +1754,50 @@ async function searchWorkspace({
});
}

/**
* Lists relative files within the workspace directory registered by an attached worker.
*
* @param {Object} params
* @param {string} params.workspace_id
* @param {string} [params.path]
* @param {string} [params.after_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 listWorkspaceFiles({
workspace_id,
path,
after_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: 'list_files',
workspaceId: workspace_id,
...(path ? { path } : {}),
...(after_path ? { afterPath: after_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 @@ -2022,6 +2066,7 @@ module.exports = {
prepareCodeOutputForInspection,
readWorkspaceFile,
searchWorkspace,
listWorkspaceFiles,
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 @@ -250,6 +250,7 @@ const {
readSandboxFile,
readWorkspaceFile,
searchWorkspace,
listWorkspaceFiles,
readSandboxImage,
writeSandboxFile,
primeFiles,
Expand Down Expand Up @@ -1940,6 +1941,53 @@ describe('Code Process', () => {
});
});

describe('listWorkspaceFiles', () => {
it('forwards authenticated listing to the selected attached worker', async () => {
const controller = new AbortController();
const result = {
protocolVersion: 1,
operation: 'list_files',
workspaceId: 'primary',
paths: ['src/app.ts'],
truncated: false,
};
getCodeApiAuthHeaders.mockResolvedValueOnce({ Authorization: 'Bearer workspace-token' });
mockExecuteWorkspaceTool.mockResolvedValueOnce(result);

await expect(
listWorkspaceFiles({
workspace_id: 'primary',
path: 'src',
after_path: 'src/app.ts',
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: 'list_files',
workspaceId: 'primary',
path: 'src',
afterPath: 'src/app.ts',
maxResults: 20,
},
signal: controller.signal,
});
});
});

describe('readSandboxFile', () => {
/**
* `readSandboxFile` shells `cat <file_path>` through the sandbox
Expand Down
7 changes: 6 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,
LIST_WORKSPACE_FILES_TOOL_NAME,
SEARCH_WORKSPACE_TOOL_NAME,
getTransactionsConfig,
} = require('@librechat/api');
Expand Down Expand Up @@ -2002,8 +2003,12 @@ async function loadToolsForExecution({
const isBashToolRequested = toolNames.includes(AgentConstants.BASH_TOOL);
const isLegacyExecuteCodeRequested = toolNames.includes(Tools.execute_code);
const isWorkspaceSearchRequested = toolNames.includes(SEARCH_WORKSPACE_TOOL_NAME);
const isWorkspaceListRequested = toolNames.includes(LIST_WORKSPACE_FILES_TOOL_NAME);
const isCodeExecutionToolRequested =
isBashToolRequested || isLegacyExecuteCodeRequested || isWorkspaceSearchRequested;
isBashToolRequested ||
isLegacyExecuteCodeRequested ||
isWorkspaceSearchRequested ||
isWorkspaceListRequested;
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 @@ -2598,6 +2598,39 @@ describe('ToolService - Action Capability Gating', () => {
}
});

it('preserves attached routing when workspace listing 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: ['list_workspace_files'],
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 @@ -516,6 +516,15 @@ describe('selection policy at injection time', () => {
expect(backgroundToolNames).toEqual([]);
});

it('keeps host-side workspace file listing in the foreground', () => {
const { backgroundToolNames } = applyBackgroundToolCalls({
toolDefinitions: [mcpDef('list_workspace_files')],
toolRegistry: undefined,
toolOptions: { list_workspace_files: { 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
8 changes: 7 additions & 1 deletion packages/api/src/agents/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,19 @@ import {
type BackgroundToolDeadClaimRecovery,
type BackgroundToolWakeupAdmission,
} from './backgroundCompletion';
import {
CREATE_FILE_TOOL_NAME,
EDIT_FILE_TOOL_NAME,
SEARCH_WORKSPACE_TOOL_NAME,
LIST_WORKSPACE_FILES_TOOL_NAME,
} from './tools';
import {
resolveToolOption,
getSelectionNames,
warnUnmatchedSelectionNames,
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';
Expand Down Expand Up @@ -113,6 +118,7 @@ const EXCLUDED_BACKGROUND_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
CREATE_FILE_TOOL_NAME,
EDIT_FILE_TOOL_NAME,
SEARCH_WORKSPACE_TOOL_NAME,
LIST_WORKSPACE_FILES_TOOL_NAME,
SET_MEMORY_TOOL_NAME,
DELETE_MEMORY_TOOL_NAME,
ASK_USER_QUESTION_TOOL_NAME,
Expand Down
Loading
Loading