diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 1727ad610e7..a27555c2e25 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -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, @@ -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 () => ({ diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 9bf6e6cff42..f54a6d49dbd 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -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'; @@ -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', @@ -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', @@ -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', @@ -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)); @@ -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)); diff --git a/packages/api/src/code/paths.ts b/packages/api/src/code/paths.ts new file mode 100644 index 00000000000..d2a0bd02fc5 --- /dev/null +++ b/packages/api/src/code/paths.ts @@ -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 = 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; +}