feat(agent-documents): add listDocuments, readDocumentByFilename, upsertDocumentByFilename APIs

This commit is contained in:
Innei
2026-03-25 19:42:15 +08:00
parent f6c7af381c
commit 8292db5702
12 changed files with 363 additions and 1 deletions

View File

@@ -4,10 +4,13 @@ import type {
CopyDocumentArgs,
CreateDocumentArgs,
EditDocumentArgs,
ListDocumentsArgs,
ReadDocumentArgs,
ReadDocumentByFilenameArgs,
RemoveDocumentArgs,
RenameDocumentArgs,
UpdateLoadRuleArgs,
UpsertDocumentByFilenameArgs,
} from '../types';
interface AgentDocumentRecord {
@@ -36,11 +39,21 @@ export interface AgentDocumentsRuntimeService {
agentId: string;
},
) => Promise<AgentDocumentRecord | undefined>;
listDocuments: (
params: ListDocumentsArgs & {
agentId: string;
},
) => Promise<AgentDocumentRecord[]>;
readDocument: (
params: ReadDocumentArgs & {
agentId: string;
},
) => Promise<AgentDocumentRecord | undefined>;
readDocumentByFilename: (
params: ReadDocumentByFilenameArgs & {
agentId: string;
},
) => Promise<AgentDocumentRecord | undefined>;
removeDocument: (
params: RemoveDocumentArgs & {
agentId: string;
@@ -56,6 +69,11 @@ export interface AgentDocumentsRuntimeService {
agentId: string;
},
) => Promise<AgentDocumentRecord | undefined>;
upsertDocumentByFilename: (
params: UpsertDocumentByFilenameArgs & {
agentId: string;
},
) => Promise<AgentDocumentRecord | undefined>;
}
export class AgentDocumentsExecutionRuntime {
@@ -66,6 +84,72 @@ export class AgentDocumentsExecutionRuntime {
return context.agentId;
}
async listDocuments(
_args: ListDocumentsArgs,
context?: AgentDocumentOperationContext,
): Promise<BuiltinServerRuntimeOutput> {
const agentId = this.resolveAgentId(context);
if (!agentId) {
return {
content: 'Cannot list agent documents without agentId context.',
success: false,
};
}
const docs = await this.service.listDocuments({ agentId });
const list = docs.map((d) => ({ filename: d.title, id: d.id, title: d.title }));
return {
content: JSON.stringify(list),
state: { documents: list },
success: true,
};
}
async readDocumentByFilename(
args: ReadDocumentByFilenameArgs,
context?: AgentDocumentOperationContext,
): Promise<BuiltinServerRuntimeOutput> {
const agentId = this.resolveAgentId(context);
if (!agentId) {
return {
content: 'Cannot read agent document without agentId context.',
success: false,
};
}
const doc = await this.service.readDocumentByFilename({ ...args, agentId });
if (!doc) return { content: `Document not found: ${args.filename}`, success: false };
return {
content: doc.content || '',
state: { content: doc.content, filename: args.filename, id: doc.id, title: doc.title },
success: true,
};
}
async upsertDocumentByFilename(
args: UpsertDocumentByFilenameArgs,
context?: AgentDocumentOperationContext,
): Promise<BuiltinServerRuntimeOutput> {
const agentId = this.resolveAgentId(context);
if (!agentId) {
return {
content: 'Cannot upsert agent document without agentId context.',
success: false,
};
}
const doc = await this.service.upsertDocumentByFilename({ ...args, agentId });
if (!doc) return { content: `Failed to upsert document: ${args.filename}`, success: false };
return {
content: `Upserted document "${args.filename}" (${doc.id}).`,
state: { filename: args.filename, id: doc.id },
success: true,
};
}
async createDocument(
args: CreateDocumentArgs,
context?: AgentDocumentOperationContext,

View File

@@ -7,10 +7,13 @@ import {
type CopyDocumentArgs,
type CreateDocumentArgs,
type EditDocumentArgs,
type ListDocumentsArgs,
type ReadDocumentArgs,
type ReadDocumentByFilenameArgs,
type RemoveDocumentArgs,
type RenameDocumentArgs,
type UpdateLoadRuleArgs,
type UpsertDocumentByFilenameArgs,
} from '../types';
export class AgentDocumentsExecutor extends BaseExecutor<typeof AgentDocumentsApiName> {
@@ -24,6 +27,27 @@ export class AgentDocumentsExecutor extends BaseExecutor<typeof AgentDocumentsAp
this.runtime = runtime;
}
listDocuments = async (
params: ListDocumentsArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.listDocuments(params, { agentId: ctx.agentId });
};
readDocumentByFilename = async (
params: ReadDocumentByFilenameArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.readDocumentByFilename(params, { agentId: ctx.agentId });
};
upsertDocumentByFilename = async (
params: UpsertDocumentByFilenameArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.upsertDocumentByFilename(params, { agentId: ctx.agentId });
};
createDocument = async (
params: CreateDocumentArgs,
ctx: BuiltinToolContext,
@@ -78,10 +102,13 @@ const fallbackRuntime = new AgentDocumentsExecutionRuntime({
copyDocument: async ({ agentId: _agentId }) => undefined,
createDocument: async () => undefined,
editDocument: async ({ agentId: _agentId }) => undefined,
listDocuments: async () => [],
readDocument: async ({ agentId: _agentId }) => undefined,
readDocumentByFilename: async () => undefined,
removeDocument: async ({ agentId: _agentId }) => false,
renameDocument: async ({ agentId: _agentId }) => undefined,
updateLoadRule: async ({ agentId: _agentId }) => undefined,
upsertDocumentByFilename: async () => undefined,
});
export const agentDocumentsExecutor = new AgentDocumentsExecutor(fallbackRuntime);

View File

@@ -10,7 +10,11 @@ export {
type CreateDocumentState,
type EditDocumentArgs,
type EditDocumentState,
type ListDocumentsArgs,
type ListDocumentsState,
type ReadDocumentArgs,
type ReadDocumentByFilenameArgs,
type ReadDocumentByFilenameState,
type ReadDocumentState,
type RemoveDocumentArgs,
type RemoveDocumentState,
@@ -18,4 +22,6 @@ export {
type RenameDocumentState,
type UpdateLoadRuleArgs,
type UpdateLoadRuleState,
type UpsertDocumentByFilenameArgs,
type UpsertDocumentByFilenameState,
} from './types';

View File

@@ -109,6 +109,50 @@ export const AgentDocumentsManifest: BuiltinToolManifest = {
type: 'object',
},
},
{
description:
'List all agent documents. Returns document id, filename, and title for each document.',
name: AgentDocumentsApiName.listDocuments,
parameters: {
properties: {},
required: [],
type: 'object',
},
},
{
description:
'Read an existing agent document by its filename (similar intent to cat by filename). Use when you know the filename but not the id.',
name: AgentDocumentsApiName.readDocumentByFilename,
parameters: {
properties: {
filename: {
description: 'Target document filename.',
type: 'string',
},
},
required: ['filename'],
type: 'object',
},
},
{
description:
'Create or update an agent document by filename. If a document with the given filename exists, its content is updated; otherwise a new document is created.',
name: AgentDocumentsApiName.upsertDocumentByFilename,
parameters: {
properties: {
content: {
description: 'Document content in markdown or plain text.',
type: 'string',
},
filename: {
description: 'Target document filename.',
type: 'string',
},
},
required: ['filename', 'content'],
type: 'object',
},
},
{
description:
'Update agent-document load rules. Use this to control how documents are loaded into runtime context.',
@@ -145,7 +189,7 @@ export const AgentDocumentsManifest: BuiltinToolManifest = {
meta: {
avatar: '🗂️',
description:
'Manage agent-scoped documents (create/read/edit/remove/rename/copy) and load rules',
'Manage agent-scoped documents (list/create/read/edit/remove/rename/copy/upsert) and load rules',
title: 'Documents',
},
systemRole: systemPrompt,

View File

@@ -4,10 +4,13 @@ export const AgentDocumentsApiName = {
createDocument: 'createDocument',
copyDocument: 'copyDocument',
editDocument: 'editDocument',
listDocuments: 'listDocuments',
readDocument: 'readDocument',
readDocumentByFilename: 'readDocumentByFilename',
removeDocument: 'removeDocument',
renameDocument: 'renameDocument',
updateLoadRule: 'updateLoadRule',
upsertDocumentByFilename: 'upsertDocumentByFilename',
} as const;
export interface CreateDocumentArgs {
@@ -103,3 +106,31 @@ export interface AgentDocumentReference {
id: string;
title?: string;
}
export interface ListDocumentsArgs {}
export interface ListDocumentsState {
documents: { filename: string; id: string; title?: string }[];
}
export interface ReadDocumentByFilenameArgs {
filename: string;
}
export interface ReadDocumentByFilenameState {
content?: string;
filename: string;
id: string;
title?: string;
}
export interface UpsertDocumentByFilenameArgs {
content: string;
filename: string;
}
export interface UpsertDocumentByFilenameState {
created: boolean;
filename: string;
id: string;
}

View File

@@ -133,10 +133,13 @@ export default {
'builtins.lobe-agent-documents.apiName.copyDocument': 'Copy document',
'builtins.lobe-agent-documents.apiName.createDocument': 'Create document',
'builtins.lobe-agent-documents.apiName.editDocument': 'Edit document',
'builtins.lobe-agent-documents.apiName.listDocuments': 'List documents',
'builtins.lobe-agent-documents.apiName.readDocument': 'Read document',
'builtins.lobe-agent-documents.apiName.readDocumentByFilename': 'Read document by filename',
'builtins.lobe-agent-documents.apiName.removeDocument': 'Remove document',
'builtins.lobe-agent-documents.apiName.renameDocument': 'Rename document',
'builtins.lobe-agent-documents.apiName.updateLoadRule': 'Update load rule',
'builtins.lobe-agent-documents.apiName.upsertDocumentByFilename': 'Upsert document by filename',
'builtins.lobe-agent-documents.title': 'Agent Documents',
'builtins.lobe-notebook.actions.collapse': 'Collapse',
'builtins.lobe-notebook.actions.copy': 'Copy',

View File

@@ -184,6 +184,48 @@ export const agentDocumentRouter = router({
return ctx.agentDocumentService.hasDocuments(input.agentId);
}),
/**
* Tool-oriented: list documents for an agent
*/
listDocuments: agentDocumentProcedure
.input(z.object({ agentId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.agentDocumentService.listDocuments(input.agentId);
}),
/**
* Tool-oriented: read document by filename
*/
readDocumentByFilename: agentDocumentProcedure
.input(
z.object({
agentId: z.string(),
filename: z.string(),
}),
)
.query(async ({ ctx, input }) => {
return ctx.agentDocumentService.getDocumentByFilename(input.agentId, input.filename);
}),
/**
* Tool-oriented: upsert document by filename
*/
upsertDocumentByFilename: agentDocumentProcedure
.input(
z.object({
agentId: z.string(),
content: z.string(),
filename: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
return ctx.agentDocumentService.upsertDocumentByFilename({
agentId: input.agentId,
content: input.content,
filename: input.filename,
});
}),
/**
* Tool-oriented: create document
*/

View File

@@ -20,8 +20,10 @@ describe('AgentDocumentsService', () => {
const mockModel = {
create: vi.fn(),
findByAgent: vi.fn(),
findByFilename: vi.fn(),
hasByAgent: vi.fn(),
upsert: vi.fn(),
};
beforeEach(() => {
@@ -67,6 +69,71 @@ describe('AgentDocumentsService', () => {
});
});
describe('listDocuments', () => {
it('should return a list of documents with filename, id, and title', async () => {
mockModel.findByAgent.mockResolvedValue([
{ content: 'c1', filename: 'a.md', id: 'doc-1', policy: null, title: 'A' },
{ content: 'c2', filename: 'b.md', id: 'doc-2', policy: null, title: 'B' },
]);
const service = new AgentDocumentsService(db, userId);
const result = await service.listDocuments('agent-1');
expect(mockModel.findByAgent).toHaveBeenCalledWith('agent-1');
expect(result).toEqual([
{ filename: 'a.md', id: 'doc-1', loadPosition: undefined, title: 'A' },
{ filename: 'b.md', id: 'doc-2', loadPosition: undefined, title: 'B' },
]);
});
});
describe('getDocumentByFilename', () => {
it('should read a document by filename', async () => {
mockModel.findByFilename.mockResolvedValue({
content: 'hello',
filename: 'note.md',
id: 'doc-1',
title: 'note',
});
const service = new AgentDocumentsService(db, userId);
const result = await service.getDocumentByFilename('agent-1', 'note.md');
expect(mockModel.findByFilename).toHaveBeenCalledWith('agent-1', 'note.md');
expect(result).toEqual({
content: 'hello',
filename: 'note.md',
id: 'doc-1',
title: 'note',
});
});
it('should return undefined when filename does not exist', async () => {
mockModel.findByFilename.mockResolvedValue(undefined);
const service = new AgentDocumentsService(db, userId);
const result = await service.getDocumentByFilename('agent-1', 'missing.md');
expect(result).toBeUndefined();
});
});
describe('upsertDocumentByFilename', () => {
it('should create or update a document by filename', async () => {
mockModel.upsert.mockResolvedValue({ content: 'new', filename: 'f.md', id: 'doc-1' });
const service = new AgentDocumentsService(db, userId);
const result = await service.upsertDocumentByFilename({
agentId: 'agent-1',
content: 'new',
filename: 'f.md',
});
expect(mockModel.upsert).toHaveBeenCalledWith('agent-1', 'f.md', 'new');
expect(result).toEqual({ content: 'new', filename: 'f.md', id: 'doc-1' });
});
});
describe('hasDocuments', () => {
it('should use the model existence check', async () => {
mockModel.hasByAgent.mockResolvedValue(true);

View File

@@ -321,6 +321,32 @@ export class AgentDocumentsService {
}
}
async listDocuments(agentId: string) {
const docs = await this.agentDocumentModel.findByAgent(agentId);
return docs.map((d) => ({
filename: d.filename,
id: d.id,
loadPosition: d.policy?.context?.position,
title: d.title,
}));
}
async getDocumentByFilename(agentId: string, filename: string) {
return this.agentDocumentModel.findByFilename(agentId, filename);
}
async upsertDocumentByFilename({
agentId,
filename,
content,
}: {
agentId: string;
content: string;
filename: string;
}) {
return this.agentDocumentModel.upsert(agentId, filename, content);
}
async editDocumentById(documentId: string, content: string, expectedAgentId?: string) {
const doc = await this.getDocumentByIdInAgent(documentId, expectedAgentId);
if (!doc) return undefined;

View File

@@ -19,7 +19,13 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
createDocument: ({ agentId, content, title }) =>
service.createDocument(agentId, title, content),
editDocument: ({ agentId, content, id }) => service.editDocumentById(id, content, agentId),
listDocuments: async ({ agentId }) => {
const docs = await service.listDocuments(agentId);
return docs.map((d) => ({ id: d.id, title: d.title }));
},
readDocument: ({ agentId, id }) => service.getDocumentById(id, agentId),
readDocumentByFilename: ({ agentId, filename }) =>
service.getDocumentByFilename(agentId, filename),
removeDocument: ({ agentId, id }) => service.removeDocumentById(id, agentId),
renameDocument: ({ agentId, id, newTitle }) =>
service.renameDocumentById(id, newTitle, agentId),
@@ -29,6 +35,8 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
{ ...rule, rule: rule.rule as DocumentLoadRule | undefined },
agentId,
),
upsertDocumentByFilename: ({ agentId, content, filename }) =>
service.upsertDocumentByFilename({ agentId, content, filename }),
});
},
identifier: AgentDocumentsIdentifier,

View File

@@ -14,6 +14,22 @@ class AgentDocumentService {
return lambdaClient.agentDocument.initializeFromTemplate.mutate(params);
};
listDocuments = async (params: { agentId: string }) => {
return lambdaClient.agentDocument.listDocuments.query(params);
};
readDocumentByFilename = async (params: { agentId: string; filename: string }) => {
return lambdaClient.agentDocument.readDocumentByFilename.query(params);
};
upsertDocumentByFilename = async (params: {
agentId: string;
content: string;
filename: string;
}) => {
return lambdaClient.agentDocument.upsertDocumentByFilename.mutate(params);
};
createDocument = async (params: { agentId: string; content: string; title: string }) => {
return lambdaClient.agentDocument.createDocument.mutate(params);
};

View File

@@ -11,7 +11,13 @@ const runtime = new AgentDocumentsExecutionRuntime({
agentDocumentService.createDocument({ agentId, content, title }),
editDocument: ({ agentId, content, id }) =>
agentDocumentService.editDocument({ agentId, content, id }),
listDocuments: async ({ agentId }) => {
const docs = await agentDocumentService.listDocuments({ agentId });
return docs.map((d) => ({ id: d.id, title: d.title }));
},
readDocument: ({ agentId, id }) => agentDocumentService.readDocument({ agentId, id }),
readDocumentByFilename: ({ agentId, filename }) =>
agentDocumentService.readDocumentByFilename({ agentId, filename }),
removeDocument: async ({ agentId, id }) =>
(await agentDocumentService.removeDocument({ agentId, id })).deleted,
renameDocument: ({ agentId, id, newTitle }) =>
@@ -26,6 +32,8 @@ const runtime = new AgentDocumentsExecutionRuntime({
rule: rule.rule as DocumentLoadRule | undefined,
},
}),
upsertDocumentByFilename: ({ agentId, content, filename }) =>
agentDocumentService.upsertDocumentByFilename({ agentId, content, filename }),
});
export const agentDocumentsExecutor = new AgentDocumentsExecutor(runtime);