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
156 changes: 156 additions & 0 deletions packages/api/src/agents/handlers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6880,6 +6880,78 @@ describe('createToolExecuteHandler', () => {
);
});

describe('attached path rejections', () => {
const notFoundBody = '{"error":"Workspace path does not exist","code":"NOT_FOUND"}';
const mappedNotFoundBody = '{"error":"Workspace path does not exist","code":"INVALID_PATH"}';
const legacyBody = '{"error":"Invalid workspace path","code":"INVALID_PATH"}';
const missingParent =
'. A parent directory of "workspace/src/new/file.ts" does not exist. Create it first (for example with mkdir -p), then retry.';
const unwritable =
'. The worker could not write "workspace/src/new/file.ts". Its parent directory may not exist yet, so create it first (for example with mkdir -p) and retry; otherwise the path is a symlink or passes through one.';
const missingFile =
'. "workspace/src/new/file.ts" does not exist. If a command is still writing it, wait for that command to finish before reading it again; otherwise list its directory to find the right path.';
const unopenable =
'. The worker could not open "workspace/src/new/file.ts". The file may not exist yet (a command may still be writing it), or the path is a directory or a symlink, or passes through one. List its directory to check before retrying.';

async function rejectAuthoring(name: 'create_file' | 'edit_file', body: string) {
const rejection = jest.fn(async () => {
throw new WorkspaceToolHttpError('rejected', 422, body);
});
const handler = makeSandboxAuthoringHandler(
name === 'create_file'
? { writeWorkspaceFile: rejection }
: { editWorkspaceFile: rejection },
negotiatedEditContext(),
);
const [result] = await invokeHandler(handler, [
{
id: `call_${name}_path_rejection`,
name,
args:
name === 'create_file'
? { path: 'workspace/src/new/file.ts', content: 'export {};' }
: { path: 'workspace/src/new/file.ts', old_text: 'a', new_text: 'b' },
},
]);
expect(rejection).toHaveBeenCalledTimes(1);
expect(result.status).toBe('error');
return result.errorMessage ?? '';
}

it.each([
['create_file', 'a current worker', notFoundBody, missingParent],
[
'create_file',
'a current worker behind an older Code API',
mappedNotFoundBody,
missingParent,
],
['create_file', 'an older worker', legacyBody, unwritable],
['edit_file', 'a current worker', notFoundBody, missingFile],
['edit_file', 'a current worker behind an older Code API', mappedNotFoundBody, missingFile],
['edit_file', 'an older worker', legacyBody, unopenable],
] as const)('%s explains a path rejection from %s', async (name, _worker, body, hint) => {
const message = await rejectAuthoring(name, body);

expect(message).toContain('upstreamStatus: 422');
expect(message.endsWith(hint)).toBe(true);
});

it.each(['create_file', 'edit_file'] as const)(
'%s leaves other 422 rejections unexplained',
async (name) => {
const message = await rejectAuthoring(
name,
'{"error":"Workspace file is not UTF-8 text","code":"INVALID_REQUEST"}',
);

expect(message).toContain('INVALID_REQUEST');
expect(message).not.toContain('does not exist');
expect(message).not.toContain('could not');
},
);
});

it('blocks protected attached edit content before worker dispatch', async () => {
const previewWorkspaceEdit = jest.fn(async () => ({
protocolVersion: 1 as const,
Expand Down Expand Up @@ -8638,6 +8710,90 @@ describe('createToolExecuteHandler', () => {
},
);

describe('attached path rejections', () => {
const attachedContext: CodeExecutionContext = {
baseUrl: 'https://code.example.com',
codeSessionKey: 'attached',
executionProfile: 'stateful',
statefulSessions: true,
environmentType: 'attached',
};
const notFoundBody = '{"error":"Workspace path does not exist","code":"NOT_FOUND"}';
const mappedNotFoundBody = '{"error":"Workspace path does not exist","code":"INVALID_PATH"}';
const legacyBody = '{"error":"Invalid workspace path","code":"INVALID_PATH"}';
const missingLog =
'. "workspace/.checks/tests.log" does not exist. If a command is still writing it, wait for that command to finish before reading it again; otherwise list its directory to find the right path.';
const missingScope =
'. "workspace/.checks" does not exist. List a parent directory to find the right path.';
const argsByTool = {
read_file: { path: 'workspace/.checks/tests.log' },
list_workspace_files: { path: '.checks' },
search_workspace: { query: 'FAIL', path: '.checks' },
};

async function reject(
name: 'read_file' | 'list_workspace_files' | 'search_workspace',
body: string,
) {
const rejection = jest.fn(async () => {
throw new WorkspaceToolHttpError('rejected', 422, body);
});
const handler = makeReadFileHandler({
codeEnvAvailable: true,
codeExecutionContext: attachedContext,
readWorkspaceFile: rejection,
listWorkspaceFiles: rejection,
searchWorkspace: rejection,
});
const [result] = await invokeHandler(handler, [
{ id: `call_${name}_path_rejection`, name, args: argsByTool[name] },
]);
expect(rejection).toHaveBeenCalledTimes(1);
expect(result.status).toBe('error');
return result.errorMessage ?? '';
}

it.each([
['read_file', 'a current worker', notFoundBody, missingLog],
['read_file', 'a current worker behind an older Code API', mappedNotFoundBody, missingLog],
[
'read_file',
'an older worker',
legacyBody,
'. The worker could not open "workspace/.checks/tests.log". The file may not exist yet (a command may still be writing it), or the path is a directory or a symlink, or passes through one. List its directory to check before retrying.',
],
['list_workspace_files', 'a current worker', notFoundBody, missingScope],
[
'list_workspace_files',
'a current worker behind an older Code API',
mappedNotFoundBody,
missingScope,
],
['search_workspace', 'a current worker', notFoundBody, missingScope],
[
'search_workspace',
'a current worker behind an older Code API',
mappedNotFoundBody,
missingScope,
],
] as const)('%s explains a missing path from %s', async (name, _worker, body, hint) => {
const message = await reject(name, body);

expect(message).toContain('upstreamStatus: 422');
expect(message.endsWith(hint)).toBe(true);
});

it.each(['list_workspace_files', 'search_workspace'] as const)(
"%s does not guess at an older worker's ambiguous path rejection",
async (name) => {
const message = await reject(name, legacyBody);

expect(message).toContain('Invalid workspace path');
expect(message.endsWith('"}"')).toBe(true);
},
);
});

it('lists files through the selected attached worker and forwards cancellation', async () => {
const controller = new AbortController();
const listWorkspaceFiles = jest.fn(async () => ({
Expand Down
25 changes: 20 additions & 5 deletions packages/api/src/agents/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ import { formatEditConflict, parseEditConflict } from '~/code/edits';
import { BACKGROUND_TOOL_INVOCATION_CONFIG_KEY } from './invocation';
import { mergeCodeFilesIntoContext } from './codeFilesSession';
import { toolValidationFeedback } from './validationFeedback';
import { explainWorkspacePathRejection } from '~/code/paths';
import { createSkillContentDigest } from './compatibility';
import { isMissingSandboxPathError } from '~/files/code';
import { applyHostTextEdits } from './files/processing';
Expand Down Expand Up @@ -2404,7 +2405,9 @@ async function handleWorkspaceFileRead(
}),
);
} catch (error) {
if (error instanceof WorkspaceToolHttpError) throw error;
if (error instanceof WorkspaceToolHttpError) {
throw explainWorkspacePathRejection(error, 'read', `workspace/${filePath}`);
}
if (signal?.aborted === true && isAbortError(error)) throw error;
logger.warn(
'[handleWorkspaceFileRead] Attached workspace read failed',
Expand Down Expand Up @@ -2498,7 +2501,13 @@ async function handleWorkspaceSearchCall(
content: truncated ? `${content}${truncationNotice}` : content,
};
} catch (error) {
if (error instanceof WorkspaceToolHttpError) throw error;
if (error instanceof WorkspaceToolHttpError) {
throw explainWorkspacePathRejection(
error,
'search',
`workspace/${typeof args.path === 'string' ? args.path : ''}`,
);
}
if (signal?.aborted === true && isAbortError(error)) throw error;
logger.warn(
'[handleWorkspaceSearchCall] Attached workspace search failed',
Expand Down Expand Up @@ -2608,7 +2617,13 @@ async function handleWorkspaceListCall(
content: `${content}${truncationNotice}`,
};
} catch (error) {
if (error instanceof WorkspaceToolHttpError) throw error;
if (error instanceof WorkspaceToolHttpError) {
throw explainWorkspacePathRejection(
error,
'list',
`workspace/${typeof args.path === 'string' ? args.path : ''}`,
);
}
if (signal?.aborted === true && isAbortError(error)) throw error;
logger.warn(
'[handleWorkspaceListCall] Attached workspace file listing failed',
Expand Down Expand Up @@ -3919,7 +3934,7 @@ async function handleAttachedWorkspaceCreateFileCall({
if (error.upstreamStatus === 409 && !overwrite) {
error.message += '. File already exists. Pass overwrite: true to replace.';
}
throw error;
throw explainWorkspacePathRejection(error, 'write', `workspace/${path.filePath}`);
}
if (signal?.aborted === true && isAbortError(error)) throw error;
logger.warn('[file_authoring] Attached workspace write failed', getSafeErrorMetadata(error));
Expand Down Expand Up @@ -4100,7 +4115,7 @@ async function handleAttachedWorkspaceEditFileCall({
if (error.upstreamStatus === 409) {
throw sanitizedEditConflict(error, describeAttachedEditConflict(path.filePath, error));
}
throw error;
throw explainWorkspacePathRejection(error, 'edit', `workspace/${path.filePath}`);
}
if (signal?.aborted === true && isAbortError(error)) throw error;
logger.warn('[file_authoring] Attached workspace edit failed', getSafeErrorMetadata(error));
Expand Down
73 changes: 73 additions & 0 deletions packages/api/src/code/paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { WorkspaceToolHttpError } from './workspace';

/** The attached file tools whose path rejections LibreChat explains to the model. */
export type WorkspacePathOperation = 'read' | 'write' | 'edit' | 'search' | 'list';

/**
* `missing`: the worker reports the path absent, as `NOT_FOUND` or, through a Code API that
* predates that code, as `INVALID_PATH` with the same message. `unusable`: an older worker's
* `INVALID_PATH`, which it also returns for a file that does not exist.
*/
type WorkspacePathRejection = 'missing' | 'unusable';

const WORKSPACE_PATH_MISSING_MESSAGE = 'Workspace path does not exist';

/** Older workers cannot tell a missing file from an unusable path, so only these get that hint. */
const LEGACY_REJECTION_OPERATIONS: ReadonlySet<WorkspacePathOperation> = new Set([
'read',
'write',
'edit',
]);

function getWorkspacePathRejection(
error: WorkspaceToolHttpError,
): WorkspacePathRejection | undefined {
if (error.reason !== 'rejected' || error.upstreamStatus !== 422 || !error.upstreamBody) {
return undefined;
}
try {
const parsed: { code?: unknown; error?: unknown } | null = JSON.parse(error.upstreamBody);
if (parsed?.code === 'NOT_FOUND') return 'missing';
if (parsed?.code !== 'INVALID_PATH') return undefined;
return parsed.error === WORKSPACE_PATH_MISSING_MESSAGE ? 'missing' : 'unusable';
} catch {
return undefined;
}
}

function describeMissingPath(operation: WorkspacePathOperation, displayPath: string): string {
const quoted = JSON.stringify(displayPath);
if (operation === 'write') {
return `A parent directory of ${quoted} does not exist. Create it first (for example with mkdir -p), then retry.`;
}
if (operation === 'search' || operation === 'list') {
return `${quoted} does not exist. List a parent directory to find the right path.`;
}
return `${quoted} does not exist. If a command is still writing it, wait for that command to finish before reading it again; otherwise list its directory to find the right path.`;
}

function describeUnusablePath(operation: WorkspacePathOperation, displayPath: string): string {
const quoted = JSON.stringify(displayPath);
if (operation === 'write') {
return `The worker could not write ${quoted}. Its parent directory may not exist yet, so create it first (for example with mkdir -p) and retry; otherwise the path is a symlink or passes through one.`;
}
return `The worker could not open ${quoted}. The file may not exist yet (a command may still be writing it), or the path is a directory or a symlink, or passes through one. List its directory to check before retrying.`;
}

/**
* Appends a short explanation to a worker's path rejection so the model can tell a file that
* does not exist yet from a malformed path. Other rejections are returned unchanged.
*/
export function explainWorkspacePathRejection(
error: WorkspaceToolHttpError,
operation: WorkspacePathOperation,
displayPath: string,
): WorkspaceToolHttpError {
const rejection = getWorkspacePathRejection(error);
if (rejection === 'missing') {
error.message += `. ${describeMissingPath(operation, displayPath)}`;
} else if (rejection === 'unusable' && LEGACY_REJECTION_OPERATIONS.has(operation)) {
error.message += `. ${describeUnusablePath(operation, displayPath)}`;
}
return error;
}
Loading