feat(server): improve doc tools error handle (#14662)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Centralized sync/status messages for cloud document sync and explicit
user-facing error types.
* Frontend helpers to detect and display tool errors with friendly
names.

* **Bug Fixes**
* Consistent, actionable error reporting for document and attachment
reads instead of silent failures.
* Search and semantic tools now validate workspace sync and permissions
and return clear responses.

* **Tests**
* Added comprehensive tests covering document/blob reads, search tools,
and sync/error paths.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-03-16 02:20:35 +08:00
committed by GitHub
parent 8f03090780
commit 121c0d172d
11 changed files with 419 additions and 70 deletions
@@ -470,7 +470,8 @@ export abstract class CopilotProvider<C = any> {
});
const searchDocs = buildDocKeywordSearchGetter(
ac,
indexerService
indexerService,
models
);
tools.doc_keyword_search = createDocKeywordSearchTool(
searchDocs.bind(null, options)
@@ -18,7 +18,10 @@ export const buildBlobContentGetter = (
chunk?: number
) => {
if (!options?.user || !options?.workspace || !blobId || !context) {
return;
return toolError(
'Blob Read Failed',
'Missing workspace, user, blob id, or copilot context for blob_read.'
);
}
const canAccess = await ac
.user(options.user)
@@ -29,7 +32,10 @@ export const buildBlobContentGetter = (
logger.warn(
`User ${options.user} does not have access workspace ${options.workspace}`
);
return;
return toolError(
'Blob Read Failed',
'You do not have permission to access this workspace attachment.'
);
}
const contextFile = context.files.find(
@@ -42,7 +48,12 @@ export const buildBlobContentGetter = (
context.getBlobContent(canonicalBlobId, chunk),
]);
const content = file?.trim() || blob?.trim();
if (!content) return;
if (!content) {
return toolError(
'Blob Read Failed',
`Attachment ${canonicalBlobId} is not available for reading in the current copilot context.`
);
}
const info = contextFile
? { fileName: contextFile.name, fileType: contextFile.mimeType }
: {};
@@ -53,10 +64,7 @@ export const buildBlobContentGetter = (
};
export const createBlobReadTool = (
getBlobContent: (
targetId?: string,
chunk?: number
) => Promise<object | undefined>
getBlobContent: (targetId?: string, chunk?: number) => Promise<object>
) => {
return defineTool({
description:
@@ -73,13 +81,10 @@ export const createBlobReadTool = (
execute: async ({ blob_id, chunk }) => {
try {
const blob = await getBlobContent(blob_id, chunk);
if (!blob) {
return;
}
return { ...blob };
} catch (err: any) {
logger.error(`Failed to read the blob ${blob_id} in context`, err);
return toolError('Blob Read Failed', err.message);
return toolError('Blob Read Failed', err.message ?? String(err));
}
},
});
@@ -1,27 +1,43 @@
import { z } from 'zod';
import type { AccessController } from '../../../core/permission';
import type { Models } from '../../../models';
import type { IndexerService, SearchDoc } from '../../indexer';
import { workspaceSyncRequiredError } from './doc-sync';
import { toolError } from './error';
import { defineTool } from './tool';
import type { CopilotChatOptions } from './types';
export const buildDocKeywordSearchGetter = (
ac: AccessController,
indexerService: IndexerService
indexerService: IndexerService,
models: Models
) => {
const searchDocs = async (options: CopilotChatOptions, query?: string) => {
if (!options || !query?.trim() || !options.user || !options.workspace) {
return undefined;
const queryTrimmed = query?.trim();
if (!options || !queryTrimmed || !options.user || !options.workspace) {
return toolError(
'Doc Keyword Search Failed',
'Missing workspace, user, or query for doc_keyword_search.'
);
}
const workspace = await models.workspace.get(options.workspace);
if (!workspace) {
return workspaceSyncRequiredError();
}
const canAccess = await ac
.user(options.user)
.workspace(options.workspace)
.can('Workspace.Read');
if (!canAccess) return undefined;
if (!canAccess) {
return toolError(
'Doc Keyword Search Failed',
'You do not have permission to access this workspace.'
);
}
const docs = await indexerService.searchDocsByKeyword(
options.workspace,
query
queryTrimmed
);
// filter current user readable docs
@@ -29,13 +45,15 @@ export const buildDocKeywordSearchGetter = (
.user(options.user)
.workspace(options.workspace)
.docs(docs, 'Doc.Read');
return readableDocs;
return readableDocs ?? [];
};
return searchDocs;
};
export const createDocKeywordSearchTool = (
searchDocs: (query: string) => Promise<SearchDoc[] | undefined>
searchDocs: (
query: string
) => Promise<SearchDoc[] | ReturnType<typeof toolError>>
) => {
return defineTool({
description:
@@ -50,8 +68,8 @@ export const createDocKeywordSearchTool = (
execute: async ({ query }) => {
try {
const docs = await searchDocs(query);
if (!docs) {
return;
if (!Array.isArray(docs)) {
return docs;
}
return docs.map(doc => ({
docId: doc.docId,
@@ -3,13 +3,20 @@ import { z } from 'zod';
import { DocReader } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { Models, publicUserSelect } from '../../../models';
import { toolError } from './error';
import { Models } from '../../../models';
import {
documentSyncPendingError,
workspaceSyncRequiredError,
} from './doc-sync';
import { type ToolError, toolError } from './error';
import { defineTool } from './tool';
import type { CopilotChatOptions } from './types';
const logger = new Logger('DocReadTool');
const isToolError = (result: ToolError | object): result is ToolError =>
'type' in result && result.type === 'error';
export const buildDocContentGetter = (
ac: AccessController,
docReader: DocReader,
@@ -17,8 +24,17 @@ export const buildDocContentGetter = (
) => {
const getDoc = async (options: CopilotChatOptions, docId?: string) => {
if (!options?.user || !options?.workspace || !docId) {
return;
return toolError(
'Doc Read Failed',
'Missing workspace, user, or document id for doc_read.'
);
}
const workspace = await models.workspace.get(options.workspace);
if (!workspace) {
return workspaceSyncRequiredError();
}
const canAccess = await ac
.user(options.user)
.workspace(options.workspace)
@@ -28,23 +44,15 @@ export const buildDocContentGetter = (
logger.warn(
`User ${options.user} does not have access to doc ${docId} in workspace ${options.workspace}`
);
return;
return toolError(
'Doc Read Failed',
`You do not have permission to read document ${docId} in this workspace.`
);
}
const docMeta = await models.doc.getSnapshot(options.workspace, docId, {
select: {
createdAt: true,
updatedAt: true,
createdByUser: {
select: publicUserSelect,
},
updatedByUser: {
select: publicUserSelect,
},
},
});
const docMeta = await models.doc.getAuthors(options.workspace, docId);
if (!docMeta) {
return;
return documentSyncPendingError(docId);
}
const content = await docReader.getDocMarkdown(
@@ -53,7 +61,7 @@ export const buildDocContentGetter = (
true
);
if (!content) {
return;
return documentSyncPendingError(docId);
}
return {
@@ -69,8 +77,12 @@ export const buildDocContentGetter = (
return getDoc;
};
type DocReadToolResult = Awaited<
ReturnType<ReturnType<typeof buildDocContentGetter>>
>;
export const createDocReadTool = (
getDoc: (targetId?: string) => Promise<object | undefined>
getDoc: (targetId?: string) => Promise<DocReadToolResult>
) => {
return defineTool({
description:
@@ -81,13 +93,10 @@ export const createDocReadTool = (
execute: async ({ doc_id }) => {
try {
const doc = await getDoc(doc_id);
if (!doc) {
return;
}
return { ...doc };
return isToolError(doc) ? doc : { ...doc };
} catch (err: any) {
logger.error(`Failed to read the doc ${doc_id}`, err);
return toolError('Doc Read Failed', err.message);
return toolError('Doc Read Failed', err.message ?? String(err));
}
},
});
@@ -7,6 +7,7 @@ import {
clearEmbeddingChunk,
type Models,
} from '../../../models';
import { workspaceSyncRequiredError } from './doc-sync';
import { toolError } from './error';
import { defineTool } from './tool';
import type {
@@ -27,14 +28,24 @@ export const buildDocSearchGetter = (
signal?: AbortSignal
) => {
if (!options || !query?.trim() || !options.user || !options.workspace) {
return `Invalid search parameters.`;
return toolError(
'Doc Semantic Search Failed',
'Missing workspace, user, or query for doc_semantic_search.'
);
}
const workspace = await models.workspace.get(options.workspace);
if (!workspace) {
return workspaceSyncRequiredError();
}
const canAccess = await ac
.user(options.user)
.workspace(options.workspace)
.can('Workspace.Read');
if (!canAccess)
return 'You do not have permission to access this workspace.';
return toolError(
'Doc Semantic Search Failed',
'You do not have permission to access this workspace.'
);
const [chunks, contextChunks] = await Promise.all([
context.matchWorkspaceAll(options.workspace, query, 10, signal),
docContext?.matchFiles(query, 10, signal) ?? [],
@@ -53,7 +64,7 @@ export const buildDocSearchGetter = (
fileChunks.push(...contextChunks);
}
if (!blobChunks.length && !docChunks.length && !fileChunks.length) {
return `No results found for "${query}".`;
return [];
}
const docIds = docChunks.map(c => ({
@@ -101,7 +112,7 @@ export const createDocSemanticSearchTool = (
searchDocs: (
query: string,
signal?: AbortSignal
) => Promise<ChunkSimilarity[] | string | undefined>
) => Promise<ChunkSimilarity[] | ReturnType<typeof toolError>>
) => {
return defineTool({
description:
@@ -0,0 +1,13 @@
import { toolError } from './error';
export const LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE =
'This workspace is local-only and does not have AFFiNE Cloud sync enabled yet. Ask the user to enable workspace sync, then try again.';
export const DOCUMENT_SYNC_PENDING_MESSAGE = (docId: string) =>
`Document ${docId} is not available on AFFiNE Cloud yet. Ask the user to wait for workspace sync to finish, then try again.`;
export const workspaceSyncRequiredError = () =>
toolError('Workspace Sync Required', LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE);
export const documentSyncPendingError = (docId: string) =>
toolError('Document Sync Pending', DOCUMENT_SYNC_PENDING_MESSAGE(docId));