diff --git a/packages/backend/server/src/__tests__/copilot-provider.spec.ts b/packages/backend/server/src/__tests__/copilot-provider.spec.ts index f12f39a2ae..7cb9d89c0b 100644 --- a/packages/backend/server/src/__tests__/copilot-provider.spec.ts +++ b/packages/backend/server/src/__tests__/copilot-provider.spec.ts @@ -207,6 +207,7 @@ const retry = async ( try { await callback(t); } catch (e) { + console.error(`Error during ${action}:`, e); t.log(`Error during ${action}:`, e); throw e; } @@ -483,6 +484,34 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca type: 'structured' as const, prefer: CopilotProviderType.Gemini, }, + { + promptName: ['Conversation Summary'], + messages: [ + { + role: 'user' as const, + content: '', + params: { + messages: [ + { role: 'user', content: 'what is single source of truth?' }, + { role: 'assistant', content: TestAssets.SSOT }, + ], + focus: 'technical decisions', + length: 'comprehensive', + }, + }, + ], + verifier: (t: ExecutionContext, result: string) => { + assertNotWrappedInCodeBlock(t, result); + const cleared = result.toLowerCase(); + t.assert( + cleared.includes('single source of truth') || + /single.*source/.test(cleared) || + cleared.includes('ssot'), + 'should include original keyword' + ); + }, + type: 'text' as const, + }, { promptName: [ 'Summary', diff --git a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts index 2e5d8cf04a..39f36c8243 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts @@ -366,6 +366,31 @@ Convert a multi-speaker audio recording into a structured JSON format by transcr requireAttachment: true, }, }, + { + name: 'Conversation Summary', + action: 'Conversation Summary', + model: 'gpt-4.1-2025-04-14', + messages: [ + { + role: 'system', + content: `You are an expert conversation summarizer. Your job is to distill long dialogues into clear, compact summaries that preserve every key decision, fact, and open question. When asked, always: +• Honor any explicit “focus” the user gives you. +• Match the desired length style: + - “brief” → 1-2 sentences + - “detailed” → ≈ 5 sentences or short bullet list + - “comprehensive” → full paragraph(s) covering all salient points. +• Write in neutral, third-person prose and never add new information. +Return only the summary text—no headings, labels, or commentary.`, + }, + { + role: 'user', + content: `Summarize the conversation below so it can be carried forward without loss.\n\nFocus: {{focus}}\nDesired length: {{length}}\n\nConversation:\n{{#messages}}\n{{role}}: {{content}}\n{{/messages}}`, + }, + ], + config: { + requireContent: false, + }, + }, { name: 'Summary', action: 'Summary', diff --git a/packages/backend/server/src/plugins/copilot/providers/provider.ts b/packages/backend/server/src/plugins/copilot/providers/provider.ts index ebe2f80738..a2ee14f9dd 100644 --- a/packages/backend/server/src/plugins/copilot/providers/provider.ts +++ b/packages/backend/server/src/plugins/copilot/providers/provider.ts @@ -21,6 +21,7 @@ import { buildDocKeywordSearchGetter, buildDocSearchGetter, createCodeArtifactTool, + createConversationSummaryTool, createDocComposeTool, createDocEditTool, createDocKeywordSearchTool, @@ -139,6 +140,10 @@ export abstract class CopilotProvider { if (options?.tools?.length) { this.logger.debug(`getTools: ${JSON.stringify(options.tools)}`); const ac = this.moduleRef.get(AccessController, { strict: false }); + const docReader = this.moduleRef.get(DocReader, { strict: false }); + const prompt = this.moduleRef.get(PromptService, { + strict: false, + }); for (const tool of options.tools) { const toolDef = this.getProviderSpecificTools(tool, model); @@ -150,9 +155,20 @@ export abstract class CopilotProvider { continue; } switch (tool) { + case 'codeArtifact': { + tools.code_artifact = createCodeArtifactTool(prompt, this.factory); + break; + } + case 'conversationSummary': { + tools.conversation_summary = createConversationSummaryTool( + options.session, + prompt, + this.factory + ); + break; + } case 'docEdit': { - const doc = this.moduleRef.get(DocReader, { strict: false }); - const getDocContent = buildContentGetter(ac, doc); + const getDocContent = buildContentGetter(ac, docReader); tools.doc_edit = createDocEditTool( this.factory, getDocContent.bind(null, options) @@ -163,7 +179,6 @@ export abstract class CopilotProvider { const context = this.moduleRef.get(CopilotContextService, { strict: false, }); - const docContext = options.session ? await context.getBySessionId(options.session) : null; @@ -175,9 +190,6 @@ export abstract class CopilotProvider { } case 'docKeywordSearch': { if (this.AFFiNEConfig.indexer.enabled) { - const ac = this.moduleRef.get(AccessController, { - strict: false, - }); const indexerService = this.moduleRef.get(IndexerService, { strict: false, }); @@ -192,9 +204,7 @@ export abstract class CopilotProvider { break; } case 'docRead': { - const ac = this.moduleRef.get(AccessController, { strict: false }); const models = this.moduleRef.get(Models, { strict: false }); - const docReader = this.moduleRef.get(DocReader, { strict: false }); const getDoc = buildDocContentGetter(ac, docReader, models); tools.doc_read = createDocReadTool(getDoc.bind(null, options)); break; @@ -205,23 +215,7 @@ export abstract class CopilotProvider { break; } case 'docCompose': { - const promptService = this.moduleRef.get(PromptService, { - strict: false, - }); - tools.doc_compose = createDocComposeTool( - promptService, - this.factory - ); - break; - } - case 'codeArtifact': { - const promptService = this.moduleRef.get(PromptService, { - strict: false, - }); - tools.code_artifact = createCodeArtifactTool( - promptService, - this.factory - ); + tools.doc_compose = createDocComposeTool(prompt, this.factory); break; } } diff --git a/packages/backend/server/src/plugins/copilot/providers/types.ts b/packages/backend/server/src/plugins/copilot/providers/types.ts index 1a981fe6a9..4d3945681e 100644 --- a/packages/backend/server/src/plugins/copilot/providers/types.ts +++ b/packages/backend/server/src/plugins/copilot/providers/types.ts @@ -60,6 +60,8 @@ export const VertexSchema: JSONSchema = { export const PromptConfigStrictSchema = z.object({ tools: z .enum([ + 'codeArtifact', + 'conversationSummary', // work with morph 'docEdit', // work with indexer @@ -71,7 +73,6 @@ export const PromptConfigStrictSchema = z.object({ 'webSearch', // artifact tools 'docCompose', - 'codeArtifact', ]) .array() .nullable() diff --git a/packages/backend/server/src/plugins/copilot/providers/utils.ts b/packages/backend/server/src/plugins/copilot/providers/utils.ts index 8539a038f5..b91e01ba06 100644 --- a/packages/backend/server/src/plugins/copilot/providers/utils.ts +++ b/packages/backend/server/src/plugins/copilot/providers/utils.ts @@ -6,20 +6,10 @@ import { ImagePart, TextPart, TextStreamPart, - ToolSet, } from 'ai'; import { ZodType } from 'zod'; -import { - createCodeArtifactTool, - createDocComposeTool, - createDocEditTool, - createDocKeywordSearchTool, - createDocReadTool, - createDocSemanticSearchTool, - createExaCrawlTool, - createExaSearchTool, -} from '../tools'; +import { CustomAITools } from '../tools'; import { PromptMessage, StreamObject } from './types'; type ChatMessage = CoreUserMessage | CoreAssistantMessage; @@ -385,17 +375,6 @@ export class CitationParser { } } -export interface CustomAITools extends ToolSet { - doc_edit: ReturnType; - doc_semantic_search: ReturnType; - doc_keyword_search: ReturnType; - doc_read: ReturnType; - doc_compose: ReturnType; - web_search_exa: ReturnType; - web_crawl_exa: ReturnType; - code_artifact: ReturnType; -} - type ChunkType = TextStreamPart['type']; export function toError(error: unknown): Error { @@ -451,6 +430,10 @@ export class TextStreamParser { ); result = this.addPrefix(result); switch (chunk.toolName) { + case 'conversation_summary': { + result += `\nSummarizing context\n`; + break; + } case 'web_search_exa': { result += `\nSearching the web "${chunk.args.query}"\n`; break; diff --git a/packages/backend/server/src/plugins/copilot/tools/conversation-summary.ts b/packages/backend/server/src/plugins/copilot/tools/conversation-summary.ts new file mode 100644 index 0000000000..9c8064e887 --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/tools/conversation-summary.ts @@ -0,0 +1,76 @@ +import { Logger } from '@nestjs/common'; +import { tool } from 'ai'; +import { z } from 'zod'; + +import type { PromptService } from '../prompt'; +import type { CopilotProviderFactory } from '../providers'; +import { toolError } from './error'; + +const logger = new Logger('ConversationSummaryTool'); + +export const createConversationSummaryTool = ( + sessionId: string | undefined, + promptService: PromptService, + factory: CopilotProviderFactory +) => { + return tool({ + description: + 'Create a concise, AI-generated summary of the conversation so far—capturing key topics, decisions, and critical details. Use this tool whenever the context becomes lengthy to preserve essential information that might otherwise be lost to truncation in future turns.', + parameters: z.object({ + focus: z + .string() + .optional() + .describe( + 'Optional focus area for the summary (e.g., "technical decisions", "user requirements", "project status")' + ), + length: z + .enum(['brief', 'detailed', 'comprehensive']) + .default('detailed') + .describe( + 'The desired length of the summary: brief (1-2 sentences), detailed (paragraph), comprehensive (multiple paragraphs)' + ), + }), + execute: async ({ focus, length }, { messages }) => { + try { + if (!messages || messages.length === 0) { + return toolError( + 'No Conversation Context', + 'No messages available to summarize' + ); + } + + const prompt = await promptService.get('Conversation Summary'); + const provider = await factory.getProviderByModel(prompt?.model || ''); + + if (!prompt || !provider) { + return toolError( + 'Prompt Not Found', + 'Failed to summarize conversation.' + ); + } + + const summary = await provider.text( + { modelId: prompt.model }, + prompt.finish({ + messages: messages.map(m => ({ + ...m, + content: m.content.toString(), + })), + focus: focus || 'general', + length, + }) + ); + + return { + focusArea: focus || 'general', + messageCount: messages.length, + summary, + timestamp: new Date().toISOString(), + }; + } catch (err: any) { + logger.error(`Failed to summarize conversation (${sessionId})`, err); + return toolError('Conversation Summary Failed', err.message); + } + }, + }); +}; diff --git a/packages/backend/server/src/plugins/copilot/tools/index.ts b/packages/backend/server/src/plugins/copilot/tools/index.ts index c20ae0f670..71588f9c52 100644 --- a/packages/backend/server/src/plugins/copilot/tools/index.ts +++ b/packages/backend/server/src/plugins/copilot/tools/index.ts @@ -1,4 +1,29 @@ +import { ToolSet } from 'ai'; + +import { createCodeArtifactTool } from './code-artifact'; +import { createConversationSummaryTool } from './conversation-summary'; +import { createDocComposeTool } from './doc-compose'; +import { createDocEditTool } from './doc-edit'; +import { createDocKeywordSearchTool } from './doc-keyword-search'; +import { createDocReadTool } from './doc-read'; +import { createDocSemanticSearchTool } from './doc-semantic-search'; +import { createExaCrawlTool } from './exa-crawl'; +import { createExaSearchTool } from './exa-search'; + +export interface CustomAITools extends ToolSet { + code_artifact: ReturnType; + conversation_summary: ReturnType; + doc_edit: ReturnType; + doc_semantic_search: ReturnType; + doc_keyword_search: ReturnType; + doc_read: ReturnType; + doc_compose: ReturnType; + web_search_exa: ReturnType; + web_crawl_exa: ReturnType; +} + export * from './code-artifact'; +export * from './conversation-summary'; export * from './doc-compose'; export * from './doc-edit'; export * from './doc-keyword-search';