From 465c9699e7e4bb952df5a2b3115dbe1362e6a2b8 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 18 Mar 2026 21:58:41 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(context-engine):=20inject=20re?= =?UTF-8?q?ferenced=20topic=20context=20into=20last=20user=20message=20(#1?= =?UTF-8?q?3104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: inject referenced topic context into last user message When users @refer_topic in chat, inject the referenced topic's summary or recent messages directly into the context, reducing unnecessary tool calls. * 🐛 fix: include agentId and groupId in message retrieval for context engineering Signed-off-by: Innei * ✨ feat: skip topic reference resolution for messages with existing topic_reference_context Added logic to prevent double injection of topic references when messages already contain the topic_reference_context. Updated tests to verify the behavior for both cases: when topic references should be resolved and when they should be skipped. Signed-off-by: Innei --------- Signed-off-by: Innei --- packages/context-engine/src/engine/index.ts | 2 + .../src/engine/messages/MessagesEngine.ts | 12 + .../src/engine/messages/index.ts | 1 + .../src/engine/messages/types.ts | 6 + .../__tests__/resolveTopicReferences.test.ts | 236 ++++++++++++++++++ .../src/engine/topicReference/index.ts | 6 + .../topicReference/resolveTopicReferences.ts | 127 ++++++++++ .../TopicReferenceContextInjector.ts | 152 +++++++++++ .../TopicReferenceContextInjector.test.ts | 207 +++++++++++++++ .../context-engine/src/providers/index.ts | 5 + .../modules/AgentRuntime/RuntimeExecutors.ts | 34 ++- .../__tests__/RuntimeExecutors.test.ts | 55 ++++ .../modules/Mecha/ContextEngineering/index.ts | 4 + .../modules/Mecha/ContextEngineering/types.ts | 5 + src/services/chat/mecha/contextEngineering.ts | 21 +- 15 files changed, 871 insertions(+), 2 deletions(-) create mode 100644 packages/context-engine/src/engine/topicReference/__tests__/resolveTopicReferences.test.ts create mode 100644 packages/context-engine/src/engine/topicReference/index.ts create mode 100644 packages/context-engine/src/engine/topicReference/resolveTopicReferences.ts create mode 100644 packages/context-engine/src/providers/TopicReferenceContextInjector.ts create mode 100644 packages/context-engine/src/providers/__tests__/TopicReferenceContextInjector.test.ts diff --git a/packages/context-engine/src/engine/index.ts b/packages/context-engine/src/engine/index.ts index 1d2a11f4b3..f0f50a7889 100644 --- a/packages/context-engine/src/engine/index.ts +++ b/packages/context-engine/src/engine/index.ts @@ -4,3 +4,5 @@ export * from './skills'; export * from './tools'; // Messages Engine export * from './messages'; +// Topic Reference +export * from './topicReference'; diff --git a/packages/context-engine/src/engine/messages/MessagesEngine.ts b/packages/context-engine/src/engine/messages/MessagesEngine.ts index 2e520dcbe5..d593ddd187 100644 --- a/packages/context-engine/src/engine/messages/MessagesEngine.ts +++ b/packages/context-engine/src/engine/messages/MessagesEngine.ts @@ -41,6 +41,7 @@ import { SystemRoleInjector, ToolDiscoveryProvider, ToolSystemRoleProvider, + TopicReferenceContextInjector, UserMemoryInjector, } from '../../providers'; import type { ContextProcessor } from '../../types'; @@ -147,6 +148,7 @@ export class MessagesEngine { initialContext, stepContext, pageContentContext, + topicReferences, enableSystemDate, timezone, } = this.params; @@ -316,6 +318,16 @@ export class MessagesEngine { // 17. GTD Todo injection (conditionally added, at end of last user message) ...(isGTDTodoEnabled ? [new GTDTodoInjector({ enabled: true, todos: gtd.todos })] : []), + // 18. Topic Reference context injection (inject referenced topic summaries to last user message) + ...(topicReferences && topicReferences.length > 0 + ? [ + new TopicReferenceContextInjector({ + enabled: true, + topicReferences, + }), + ] + : []), + // ============================================= // Phase 4: Message Transformation // ============================================= diff --git a/packages/context-engine/src/engine/messages/index.ts b/packages/context-engine/src/engine/messages/index.ts index cfee0de556..f2af6187b8 100644 --- a/packages/context-engine/src/engine/messages/index.ts +++ b/packages/context-engine/src/engine/messages/index.ts @@ -18,6 +18,7 @@ export type { ToolDiscoveryConfig, ToolDiscoveryMeta, ToolsConfig, + TopicReferenceItem, UIChatMessage, UserMemoryActivityItem, UserMemoryConfig, diff --git a/packages/context-engine/src/engine/messages/types.ts b/packages/context-engine/src/engine/messages/types.ts index f45313ea9a..bf507d776a 100644 --- a/packages/context-engine/src/engine/messages/types.ts +++ b/packages/context-engine/src/engine/messages/types.ts @@ -19,6 +19,7 @@ import type { GTDPlan } from '../../providers/GTDPlanInjector'; import type { GTDTodoList } from '../../providers/GTDTodoInjector'; import type { SkillMeta } from '../../providers/SkillContextProvider'; import type { ToolDiscoveryMeta } from '../../providers/ToolDiscoveryProvider'; +import type { TopicReferenceItem } from '../../providers/TopicReferenceContextInjector'; import type { PipelineContextMetadata } from '../../types'; import type { LobeToolManifest } from '../tools/types'; @@ -281,6 +282,10 @@ export interface MessagesEngineParams { /** User memory configuration */ userMemory?: UserMemoryConfig; + // ========== Topic References ========== + /** Topic reference summaries to inject into last user message */ + topicReferences?: TopicReferenceItem[]; + // ========== Page Editor context ========== /** * Initial context captured at operation start (frontend runtime usage) @@ -330,5 +335,6 @@ export { type GTDPlan } from '../../providers/GTDPlanInjector'; export { type GTDTodoItem, type GTDTodoList } from '../../providers/GTDTodoInjector'; export { type SkillMeta } from '../../providers/SkillContextProvider'; export { type ToolDiscoveryMeta } from '../../providers/ToolDiscoveryProvider'; +export { type TopicReferenceItem } from '../../providers/TopicReferenceContextInjector'; export { type OpenAIChatMessage, type UIChatMessage } from '@/types/index'; export { type FileContent, type KnowledgeBaseInfo } from '@lobechat/prompts'; diff --git a/packages/context-engine/src/engine/topicReference/__tests__/resolveTopicReferences.test.ts b/packages/context-engine/src/engine/topicReference/__tests__/resolveTopicReferences.test.ts new file mode 100644 index 0000000000..4afdf8a3d7 --- /dev/null +++ b/packages/context-engine/src/engine/topicReference/__tests__/resolveTopicReferences.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { parseReferTopicTags, resolveTopicReferences } from '../resolveTopicReferences'; + +// ============ parseReferTopicTags ============ + +describe('parseReferTopicTags', () => { + it('should parse refer_topic tags with name before id', () => { + const result = parseReferTopicTags([ + { content: '' }, + ]); + expect(result).toEqual([{ topicId: 'topic-1', topicTitle: 'My Topic' }]); + }); + + it('should parse refer_topic tags with id before name', () => { + const result = parseReferTopicTags([ + { content: '' }, + ]); + expect(result).toEqual([{ topicId: 'topic-2', topicTitle: 'Other Topic' }]); + }); + + it('should deduplicate topic IDs across messages', () => { + const result = parseReferTopicTags([ + { content: '' }, + { content: '' }, + ]); + expect(result).toHaveLength(1); + expect(result[0].topicId).toBe('topic-1'); + }); + + it('should parse multiple different topics', () => { + const result = parseReferTopicTags([ + { content: '\n' }, + ]); + expect(result).toHaveLength(2); + expect(result.map((r) => r.topicId)).toEqual(['t1', 't2']); + }); + + it('should return empty array when no tags found', () => { + const result = parseReferTopicTags([{ content: 'Hello world' }]); + expect(result).toEqual([]); + }); + + it('should skip non-string content', () => { + const result = parseReferTopicTags([{ content: ['array', 'content'] }]); + expect(result).toEqual([]); + }); + + it('should parse markdown-escaped refer_topic tags', () => { + const result = parseReferTopicTags([ + { content: 'hi \\' }, + ]); + expect(result).toEqual([{ topicId: 'tpc_867aVIS5Av2G', topicTitle: '新对话开始' }]); + }); + + it('should parse markdown-escaped tags at start of content', () => { + const result = parseReferTopicTags([ + { content: '\\讲了啥' }, + ]); + expect(result).toEqual([{ topicId: 'tpc_EH0s0KrwpJN2', topicTitle: 'Greeting Message' }]); + }); + + it('should parse mixed escaped and non-escaped tags', () => { + const result = parseReferTopicTags([ + { content: '\\' }, + { content: '' }, + ]); + expect(result).toHaveLength(2); + expect(result.map((r) => r.topicId)).toEqual(['tpc_1', 'tpc_2']); + }); +}); + +// ============ resolveTopicReferences ============ + +describe('resolveTopicReferences', () => { + it('should return undefined when no refer_topic tags found', async () => { + const lookup = vi.fn(); + const result = await resolveTopicReferences([{ content: 'Hello' }], lookup); + expect(result).toBeUndefined(); + expect(lookup).not.toHaveBeenCalled(); + }); + + it('should resolve topics with summaries', async () => { + const lookup = vi.fn().mockResolvedValue({ + historySummary: 'Topic summary content', + title: 'DB Title', + }); + + const result = await resolveTopicReferences( + [{ content: '' }], + lookup, + ); + + expect(result).toEqual([ + { summary: 'Topic summary content', topicId: 'topic-1', topicTitle: 'DB Title' }, + ]); + expect(lookup).toHaveBeenCalledWith('topic-1'); + }); + + it('should resolve topics without summaries', async () => { + const lookup = vi.fn().mockResolvedValue({ historySummary: null, title: 'Some Topic' }); + + const result = await resolveTopicReferences( + [{ content: '' }], + lookup, + ); + + expect(result).toEqual([{ summary: undefined, topicId: 'topic-2', topicTitle: 'Some Topic' }]); + }); + + it('should fallback to parsed title when lookup returns null', async () => { + const lookup = vi.fn().mockResolvedValue(null); + + const result = await resolveTopicReferences( + [{ content: '' }], + lookup, + ); + + expect(result).toEqual([ + { summary: undefined, topicId: 'topic-3', topicTitle: 'Parsed Title' }, + ]); + }); + + it('should handle lookup errors gracefully', async () => { + const lookup = vi.fn().mockRejectedValue(new Error('DB error')); + + const result = await resolveTopicReferences( + [{ content: '' }], + lookup, + ); + + expect(result).toEqual([{ topicId: 'topic-err', topicTitle: 'Error Topic' }]); + }); + + it('should handle mixed results', async () => { + const lookup = vi.fn().mockImplementation(async (id: string) => { + if (id === 't1') return { historySummary: 'Has summary', title: 'Topic 1' }; + return null; + }); + + const result = await resolveTopicReferences( + [{ content: '\n' }], + lookup, + ); + + expect(result).toHaveLength(2); + expect(result![0]).toEqual({ summary: 'Has summary', topicId: 't1', topicTitle: 'Topic 1' }); + expect(result![1]).toEqual({ summary: undefined, topicId: 't2', topicTitle: 'B' }); + }); + + it('should fetch recent messages as fallback when no summary and lookupMessages provided', async () => { + const lookup = vi.fn().mockResolvedValue({ historySummary: null, title: 'No Summary Topic' }); + const lookupMessages = vi.fn().mockResolvedValue([ + { content: 'Hello', role: 'user' }, + { content: 'Hi there!', role: 'assistant' }, + { content: '', role: 'tool' }, + { content: 'Follow up', role: 'user' }, + ]); + + const result = await resolveTopicReferences( + [{ content: '' }], + lookup, + lookupMessages, + ); + + expect(lookupMessages).toHaveBeenCalledWith('t1'); + expect(result).toHaveLength(1); + expect(result![0].recentMessages).toHaveLength(3); + expect(result![0].recentMessages).toEqual([ + { content: 'Hello', role: 'user' }, + { content: 'Hi there!', role: 'assistant' }, + { content: 'Follow up', role: 'user' }, + ]); + expect(result![0].summary).toBeUndefined(); + }); + + it('should truncate long message content to 300 chars', async () => { + const longContent = 'x'.repeat(500); + const lookup = vi.fn().mockResolvedValue({ historySummary: null, title: 'T' }); + const lookupMessages = vi.fn().mockResolvedValue([{ content: longContent, role: 'user' }]); + + const result = await resolveTopicReferences( + [{ content: '' }], + lookup, + lookupMessages, + ); + + expect(result![0].recentMessages![0].content).toHaveLength(303); // 300 + '...' + }); + + it('should take only last 5 messages', async () => { + const lookup = vi.fn().mockResolvedValue({ historySummary: null, title: 'T' }); + const messages = Array.from({ length: 10 }, (_, i) => ({ + content: `msg ${i}`, + role: i % 2 === 0 ? 'user' : 'assistant', + })); + const lookupMessages = vi.fn().mockResolvedValue(messages); + + const result = await resolveTopicReferences( + [{ content: '' }], + lookup, + lookupMessages, + ); + + expect(result![0].recentMessages).toHaveLength(5); + expect(result![0].recentMessages![0].content).toBe('msg 5'); + }); + + it('should skip lookupMessages when topic has summary', async () => { + const lookup = vi.fn().mockResolvedValue({ historySummary: 'Has summary', title: 'T' }); + const lookupMessages = vi.fn(); + + const result = await resolveTopicReferences( + [{ content: '' }], + lookup, + lookupMessages, + ); + + expect(lookupMessages).not.toHaveBeenCalled(); + expect(result![0].summary).toBe('Has summary'); + }); + + it('should fall through to no-context when lookupMessages returns empty', async () => { + const lookup = vi.fn().mockResolvedValue({ historySummary: null, title: 'T' }); + const lookupMessages = vi.fn().mockResolvedValue([]); + + const result = await resolveTopicReferences( + [{ content: '' }], + lookup, + lookupMessages, + ); + + expect(result![0].summary).toBeUndefined(); + expect(result![0].recentMessages).toBeUndefined(); + }); +}); diff --git a/packages/context-engine/src/engine/topicReference/index.ts b/packages/context-engine/src/engine/topicReference/index.ts new file mode 100644 index 0000000000..aa68fcca34 --- /dev/null +++ b/packages/context-engine/src/engine/topicReference/index.ts @@ -0,0 +1,6 @@ +export type { + ParsedTopicReference, + TopicLookupResult, + TopicMessageItem, +} from './resolveTopicReferences'; +export { parseReferTopicTags, resolveTopicReferences } from './resolveTopicReferences'; diff --git a/packages/context-engine/src/engine/topicReference/resolveTopicReferences.ts b/packages/context-engine/src/engine/topicReference/resolveTopicReferences.ts new file mode 100644 index 0000000000..bddbdaeff6 --- /dev/null +++ b/packages/context-engine/src/engine/topicReference/resolveTopicReferences.ts @@ -0,0 +1,127 @@ +import type { TopicReferenceItem } from '../../providers/TopicReferenceContextInjector'; + +/** + * Parsed refer_topic tag info + */ +export interface ParsedTopicReference { + topicId: string; + topicTitle?: string; +} + +/** + * Topic lookup result (common interface for both client store and server DB) + */ +export interface TopicLookupResult { + historySummary?: string | null; + title?: string | null; +} + +/** + * Message item returned by lookupMessages + */ +export interface TopicMessageItem { + content?: string | null; + role: string; +} + +/** Max recent messages to fetch as fallback */ +const MAX_RECENT_MESSAGES = 5; +/** Max characters per message content */ +const MAX_MESSAGE_LENGTH = 300; + +/** + * Parse tags from message contents + */ +export function parseReferTopicTags( + messages: Array<{ content: string | unknown }>, +): ParsedTopicReference[] { + const topicIds = new Set(); + const topicNames = new Map(); + + for (const msg of messages) { + // Strip markdown escape backslashes (e.g. \< → <, \_ → _) before parsing + const raw = typeof msg.content === 'string' ? msg.content : ''; + const content = raw.replaceAll(/\\([<>_*[\]()#`~|])/g, '$1'); + const tagRegex = /]*)>/g; + let tagMatch; + while ((tagMatch = tagRegex.exec(content)) !== null) { + const attrs = tagMatch[1]; + const idMatch = /id="([^"]+)"/.exec(attrs); + const nameMatch = /name="([^"]*)"/.exec(attrs); + if (idMatch) { + topicIds.add(idMatch[1]); + if (nameMatch) topicNames.set(idMatch[1], nameMatch[1]); + } + } + } + + return [...topicIds].map((topicId) => ({ + topicId, + topicTitle: topicNames.get(topicId), + })); +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return text.slice(0, max) + '...'; +} + +/** + * Resolve topic references by looking up each topic via the provided function. + * Works with both sync (client store) and async (server DB) data sources. + * + * When a topic has no historySummary and lookupMessages is provided, + * fetches the last 5 user/assistant messages as fallback context (each truncated to 300 chars). + */ +export async function resolveTopicReferences( + messages: Array<{ content: string | unknown }>, + lookupTopic: (topicId: string) => Promise, + lookupMessages?: (topicId: string) => Promise, +): Promise { + const parsed = parseReferTopicTags(messages); + if (parsed.length === 0) return undefined; + + const refs: TopicReferenceItem[] = []; + + for (const { topicId, topicTitle } of parsed) { + try { + const topic = await lookupTopic(topicId); + const title = topic?.title || topicTitle; + + if (topic?.historySummary) { + refs.push({ summary: topic.historySummary, topicId, topicTitle: title }); + continue; + } + + // Fallback: fetch recent messages + if (lookupMessages) { + try { + const allMessages = await lookupMessages(topicId); + + // Filter to user/assistant only, take last N, truncate content + const recent = allMessages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .filter((m) => m.content?.trim()) + .slice(-MAX_RECENT_MESSAGES) + .map((m) => ({ + content: truncate(m.content!.trim(), MAX_MESSAGE_LENGTH), + role: m.role, + })); + + if (recent.length > 0) { + refs.push({ recentMessages: recent, topicId, topicTitle: title }); + continue; + } + } catch { + // fallthrough to no-context + } + } + + refs.push({ topicId, topicTitle: title }); + } catch { + refs.push({ topicId, topicTitle }); + } + } + + return refs.length > 0 ? refs : undefined; +} diff --git a/packages/context-engine/src/providers/TopicReferenceContextInjector.ts b/packages/context-engine/src/providers/TopicReferenceContextInjector.ts new file mode 100644 index 0000000000..cfe7ea7ee6 --- /dev/null +++ b/packages/context-engine/src/providers/TopicReferenceContextInjector.ts @@ -0,0 +1,152 @@ +import debug from 'debug'; + +import { BaseLastUserContentProvider } from '../base/BaseLastUserContentProvider'; +import type { PipelineContext, ProcessorOptions } from '../types'; + +declare module '../types' { + interface PipelineContextMetadataOverrides { + topicReferenceCount?: number; + topicReferenceInjected?: boolean; + } +} + +const log = debug('context-engine:provider:TopicReferenceContextInjector'); + +/** + * Topic reference item + */ +export interface TopicReferenceItem { + /** Recent messages as fallback when no summary available */ + recentMessages?: Array<{ content: string; role: string }>; + /** The topic's history summary (undefined if no summary available) */ + summary?: string; + /** The topic ID */ + topicId: string; + /** The topic title */ + topicTitle?: string; +} + +export interface TopicReferenceContextInjectorConfig { + /** Whether topic reference context injection is enabled */ + enabled?: boolean; + /** Referenced topics (with or without summaries) */ + topicReferences?: TopicReferenceItem[]; +} + +/** + * Format topic references for injection + * Topics with summaries are inlined; topics without summaries prompt the agent to use the tool + */ +function formatRecentMessages(messages: Array<{ content: string; role: string }>): string { + return messages + .map((msg) => { + const role = msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : msg.role; + return `**${role}**: ${msg.content}`; + }) + .join('\n\n'); +} + +function formatTopicReferences(items: TopicReferenceItem[]): string | null { + if (items.length === 0) return null; + + const withContext = items.filter((item) => item.summary || item.recentMessages?.length); + const withoutContext = items.filter((item) => !item.summary && !item.recentMessages?.length); + + const lines: string[] = [ + '', + ]; + + for (const item of withContext) { + const title = item.topicTitle ? ` title="${item.topicTitle}"` : ''; + const type = item.summary ? 'summary' : 'recent_messages'; + lines.push(``); + if (item.summary) { + lines.push(item.summary); + } else { + lines.push(formatRecentMessages(item.recentMessages!)); + } + lines.push(''); + } + + if (withoutContext.length > 0) { + lines.push( + '', + ); + for (const item of withoutContext) { + const title = item.topicTitle ? ` title="${item.topicTitle}"` : ''; + lines.push(``); + } + lines.push(''); + } + + lines.push(''); + + return lines.join('\n'); +} + +/** + * Topic Reference Context Injector + * + * When user messages contain tags referencing other topics, + * this injector appends the referenced topics' context to the last user message. + * - Topics with summaries: summary is inlined directly + * - Topics without summaries: a hint is added prompting the agent to call the getTopicContext tool + */ +export class TopicReferenceContextInjector extends BaseLastUserContentProvider { + readonly name = 'TopicReferenceContextInjector'; + + constructor( + private config: TopicReferenceContextInjectorConfig, + options: ProcessorOptions = {}, + ) { + super(options); + } + + protected async doProcess(context: PipelineContext): Promise { + log('doProcess called'); + log('config.enabled:', this.config.enabled); + + const clonedContext = this.cloneContext(context); + + if ( + !this.config.enabled || + !this.config.topicReferences || + this.config.topicReferences.length === 0 + ) { + log('Topic reference not enabled or no references, skipping injection'); + return this.markAsExecuted(clonedContext); + } + + const formattedContent = formatTopicReferences(this.config.topicReferences); + + if (!formattedContent) { + log('No topic reference content to inject'); + return this.markAsExecuted(clonedContext); + } + + log('Formatted content length:', formattedContent.length); + + const lastUserIndex = this.findLastUserMessageIndex(clonedContext.messages); + + log('Last user message index:', lastUserIndex); + + if (lastUserIndex === -1) { + log('No user messages found, skipping injection'); + return this.markAsExecuted(clonedContext); + } + + const hasExistingWrapper = this.hasExistingSystemContext(clonedContext); + const contentToAppend = hasExistingWrapper + ? this.createContextBlock(formattedContent, 'topic_reference_context') + : this.wrapWithSystemContext(formattedContent, 'topic_reference_context'); + + this.appendToLastUserMessage(clonedContext, contentToAppend); + + clonedContext.metadata.topicReferenceInjected = true; + clonedContext.metadata.topicReferenceCount = this.config.topicReferences.length; + + log('Topic reference context appended to last user message'); + + return this.markAsExecuted(clonedContext); + } +} diff --git a/packages/context-engine/src/providers/__tests__/TopicReferenceContextInjector.test.ts b/packages/context-engine/src/providers/__tests__/TopicReferenceContextInjector.test.ts new file mode 100644 index 0000000000..ab7f5c7c39 --- /dev/null +++ b/packages/context-engine/src/providers/__tests__/TopicReferenceContextInjector.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; + +import type { PipelineContext } from '../../types'; +import { TopicReferenceContextInjector } from '../TopicReferenceContextInjector'; + +const createContext = (messages: any[] = []): PipelineContext => ({ + initialState: { + messages: [], + model: 'gpt-4', + provider: 'openai', + }, + isAborted: false, + messages, + metadata: { + maxTokens: 4096, + model: 'gpt-4', + }, +}); + +describe('TopicReferenceContextInjector', () => { + it('should skip when not enabled', async () => { + const injector = new TopicReferenceContextInjector({ enabled: false }); + const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]); + const result = await injector.process(context); + + expect(result.messages[0].content).toBe('Hello'); + expect(result.metadata.topicReferenceInjected).toBeUndefined(); + }); + + it('should skip when no topic references provided', async () => { + const injector = new TopicReferenceContextInjector({ enabled: true, topicReferences: [] }); + const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]); + const result = await injector.process(context); + + expect(result.messages[0].content).toBe('Hello'); + expect(result.metadata.topicReferenceInjected).toBeUndefined(); + }); + + it('should inject topic summaries into the last user message', async () => { + const injector = new TopicReferenceContextInjector({ + enabled: true, + topicReferences: [ + { + summary: 'This topic discussed React hooks and state management.', + topicId: 'topic-1', + topicTitle: 'React Hooks Discussion', + }, + ], + }); + + const context = createContext([ + { content: 'Earlier message', id: 'user-1', role: 'user' }, + { content: 'Reply', id: 'assistant-1', role: 'assistant' }, + { + content: '\nTell me more', + id: 'user-2', + role: 'user', + }, + ]); + + const result = await injector.process(context); + + expect(result.messages).toHaveLength(3); + const lastContent = result.messages[2].content as string; + expect(lastContent).toContain('', + ); + expect(lastContent).toContain('This topic discussed React hooks and state management.'); + expect(lastContent).toContain(''); + expect(result.metadata.topicReferenceInjected).toBe(true); + expect(result.metadata.topicReferenceCount).toBe(1); + }); + + it('should inject pending hint for topics without summaries', async () => { + const injector = new TopicReferenceContextInjector({ + enabled: true, + topicReferences: [ + { + topicId: 'topic-no-summary', + topicTitle: 'Old Chat', + }, + ], + }); + + const context = createContext([{ content: 'Check this topic', id: 'user-1', role: 'user' }]); + + const result = await injector.process(context); + const lastContent = result.messages[0].content as string; + + expect(lastContent).toContain(''); + expect(lastContent).not.toContain(''); + expect(result.metadata.topicReferenceInjected).toBe(true); + }); + + it('should handle mixed topics (some with summaries, some without)', async () => { + const injector = new TopicReferenceContextInjector({ + enabled: true, + topicReferences: [ + { + summary: 'Summary of topic A.', + topicId: 'topic-a', + topicTitle: 'Topic A', + }, + { + topicId: 'topic-b', + topicTitle: 'Topic B', + }, + ], + }); + + const context = createContext([ + { content: 'Refer to both topics', id: 'user-1', role: 'user' }, + ]); + + const result = await injector.process(context); + const lastContent = result.messages[0].content as string; + + expect(lastContent).toContain(''); + expect(lastContent).toContain('Summary of topic A.'); + expect(lastContent).toContain(''); + expect(result.metadata.topicReferenceCount).toBe(2); + }); + + it('should reuse existing system context wrapper', async () => { + const injector = new TopicReferenceContextInjector({ + enabled: true, + topicReferences: [ + { + summary: 'Topic summary.', + topicId: 'topic-1', + }, + ], + }); + + const context = createContext([ + { + content: `My question + + + +some context + +`, + id: 'user-1', + role: 'user', + }, + ]); + + const result = await injector.process(context); + const content = result.messages[0].content as string; + + expect(content.match(//g)).toHaveLength(1); + expect(content).toContain(''); + expect(content).toContain(''); + }); + + it('should inject recent messages when no summary available', async () => { + const injector = new TopicReferenceContextInjector({ + enabled: true, + topicReferences: [ + { + recentMessages: [ + { content: 'What is React?', role: 'user' }, + { content: 'React is a JS library.', role: 'assistant' }, + ], + topicId: 'topic-recent', + topicTitle: 'React Q&A', + }, + ], + }); + + const context = createContext([ + { content: 'Tell me about that topic', id: 'user-1', role: 'user' }, + ]); + + const result = await injector.process(context); + const lastContent = result.messages[0].content as string; + + expect(lastContent).toContain( + '', + ); + expect(lastContent).toContain('**User**: What is React?'); + expect(lastContent).toContain('**Assistant**: React is a JS library.'); + expect(lastContent).not.toContain(' { + const injector = new TopicReferenceContextInjector({ + enabled: true, + topicReferences: [{ summary: 'Summary', topicId: 'topic-1' }], + }); + + const context = createContext([ + { content: 'Assistant only', id: 'assistant-1', role: 'assistant' }, + ]); + + const result = await injector.process(context); + + expect(result.messages[0].content).toBe('Assistant only'); + expect(result.metadata.topicReferenceInjected).toBeUndefined(); + }); +}); diff --git a/packages/context-engine/src/providers/index.ts b/packages/context-engine/src/providers/index.ts index d3a0bd2080..bcc7f75464 100644 --- a/packages/context-engine/src/providers/index.ts +++ b/packages/context-engine/src/providers/index.ts @@ -18,6 +18,7 @@ export { SystemDateProvider } from './SystemDateProvider'; export { SystemRoleInjector } from './SystemRoleInjector'; export { ToolDiscoveryProvider } from './ToolDiscoveryProvider'; export { ToolSystemRoleProvider } from './ToolSystemRole'; +export { TopicReferenceContextInjector } from './TopicReferenceContextInjector'; export { UserMemoryInjector } from './UserMemoryInjector'; // Re-export types @@ -58,4 +59,8 @@ export type { SystemDateProviderConfig } from './SystemDateProvider'; export type { SystemRoleInjectorConfig } from './SystemRoleInjector'; export type { ToolDiscoveryMeta, ToolDiscoveryProviderConfig } from './ToolDiscoveryProvider'; export type { ToolSystemRoleConfig } from './ToolSystemRole'; +export type { + TopicReferenceContextInjectorConfig, + TopicReferenceItem, +} from './TopicReferenceContextInjector'; export type { MemoryContext, UserMemoryInjectorConfig } from './UserMemoryInjector'; diff --git a/src/server/modules/AgentRuntime/RuntimeExecutors.ts b/src/server/modules/AgentRuntime/RuntimeExecutors.ts index 8f7abc1664..b8b12f0905 100644 --- a/src/server/modules/AgentRuntime/RuntimeExecutors.ts +++ b/src/server/modules/AgentRuntime/RuntimeExecutors.ts @@ -14,6 +14,7 @@ import { type LobeToolManifest, type OperationToolSet, type ResolvedToolSet, + resolveTopicReferences, ToolNameResolver, ToolResolver, } from '@lobechat/context-engine'; @@ -24,7 +25,8 @@ import { type ChatToolPayload, type MessageToolCall, type UIChatMessage } from ' import { serializePartsForStorage } from '@lobechat/utils'; import debug from 'debug'; -import { type MessageModel } from '@/database/models/message'; +import { type MessageModel, MessageModel as MessageModelClass } from '@/database/models/message'; +import { TopicModel } from '@/database/models/topic'; import { type LobeChatDatabase } from '@/database/type'; import { serverMessagesEngine } from '@/server/modules/Mecha/ContextEngineering'; import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/types'; @@ -178,6 +180,33 @@ export const createRuntimeExecutors = ( if (agentConfig) { const { LOBE_DEFAULT_MODEL_LIST } = await import('model-bank'); + // Extract tags from messages and fetch summaries. + // Skip if messages already contain injected topic_reference_context + // (e.g., from client-side contextEngineering preprocessing) to avoid double injection. + let topicReferences; + const alreadyHasTopicRefs = ( + llmPayload.messages as Array<{ content: string | unknown }> + ).some( + (m) => typeof m.content === 'string' && m.content.includes('topic_reference_context'), + ); + + if (!alreadyHasTopicRefs && ctx.serverDB && ctx.userId) { + const topicModel = new TopicModel(ctx.serverDB, ctx.userId); + const messageModel = new MessageModelClass(ctx.serverDB, ctx.userId); + topicReferences = await resolveTopicReferences( + llmPayload.messages as Array<{ content: string | unknown }>, + async (topicId) => topicModel.findById(topicId), + async (topicId) => { + const topic = await topicModel.findById(topicId); + return messageModel.query({ + agentId: topic?.agentId ?? undefined, + groupId: topic?.groupId ?? undefined, + topicId, + }); + }, + ); + } + const contextEngineInput = { additionalVariables: state.metadata?.deviceSystemInfo, userTimezone: ctx.userTimezone, @@ -235,6 +264,9 @@ export const createRuntimeExecutors = ( ...(state.metadata?.skillMetas?.length && { skillsConfig: { enabledSkills: state.metadata.skillMetas }, }), + + // Topic reference summaries + ...(topicReferences && { topicReferences }), }; processedMessages = await serverMessagesEngine(contextEngineInput); diff --git a/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts b/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts index 0a4af1a93f..67aa6abf7e 100644 --- a/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts +++ b/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts @@ -1100,6 +1100,61 @@ describe('RuntimeExecutors', () => { name: 'Enabled KB', }); }); + + it('should skip topic reference resolution when messages already contain topic_reference_context', async () => { + const ctxWithConfig: RuntimeExecutorContext = { + ...ctx, + agentConfig: { plugins: [], systemRole: 'test' }, + }; + const executors = createRuntimeExecutors(ctxWithConfig); + const state = createMockState(); + + const instruction = { + payload: { + messages: [ + { + content: + '\nHello\n\n\n...\n\n', + role: 'user', + }, + ], + model: 'gpt-4', + provider: 'openai', + }, + type: 'call_llm' as const, + }; + + await executors.call_llm!(instruction, state); + + expect(engineSpy).toHaveBeenCalledTimes(1); + const callArgs = engineSpy.mock.calls[0][0]; + expect(callArgs).not.toHaveProperty('topicReferences'); + }); + + it('should resolve topic references when messages do not contain topic_reference_context', async () => { + const ctxWithConfig: RuntimeExecutorContext = { + ...ctx, + agentConfig: { plugins: [], systemRole: 'test' }, + }; + const executors = createRuntimeExecutors(ctxWithConfig); + const state = createMockState(); + + const instruction = { + payload: { + messages: [{ content: 'Just a normal message without any topic refs', role: 'user' }], + model: 'gpt-4', + provider: 'openai', + }, + type: 'call_llm' as const, + }; + + await executors.call_llm!(instruction, state); + + expect(engineSpy).toHaveBeenCalledTimes(1); + // resolveTopicReferences ran but found no tags → topicReferences is undefined + const callArgs = engineSpy.mock.calls[0][0]; + expect(callArgs).not.toHaveProperty('topicReferences'); + }); }); }); diff --git a/src/server/modules/Mecha/ContextEngineering/index.ts b/src/server/modules/Mecha/ContextEngineering/index.ts index 432015877a..c334c0a78d 100644 --- a/src/server/modules/Mecha/ContextEngineering/index.ts +++ b/src/server/modules/Mecha/ContextEngineering/index.ts @@ -68,6 +68,7 @@ export const serverMessagesEngine = async ({ evalContext, agentManagementContext, pageContentContext, + topicReferences, additionalVariables, userTimezone, }: ServerMessagesEngineParams): Promise => { @@ -140,6 +141,9 @@ export const serverMessagesEngine = async ({ // Skills configuration ...(skillsConfig?.enabledSkills && skillsConfig.enabledSkills.length > 0 && { skillsConfig }), + // Topic references + ...(topicReferences && topicReferences.length > 0 && { topicReferences }), + // Extended contexts ...(agentBuilderContext && { agentBuilderContext }), ...(discordContext && { discordContext }), diff --git a/src/server/modules/Mecha/ContextEngineering/types.ts b/src/server/modules/Mecha/ContextEngineering/types.ts index 58b08fb76a..fb23ba675b 100644 --- a/src/server/modules/Mecha/ContextEngineering/types.ts +++ b/src/server/modules/Mecha/ContextEngineering/types.ts @@ -8,6 +8,7 @@ import type { KnowledgeBaseInfo, LobeToolManifest, SkillMeta, + TopicReferenceItem, UserMemoryData, } from '@lobechat/context-engine'; import type { PageContentContext } from '@lobechat/prompts'; @@ -120,6 +121,9 @@ export interface ServerMessagesEngineParams { // ========== Tools ========== /** Tools configuration */ toolsConfig?: ServerToolsConfig; + // ========== Topic References ========== + /** Topic reference summaries to inject into last user message */ + topicReferences?: TopicReferenceItem[]; // ========== User memory ========== /** User memory configuration */ userMemory?: ServerUserMemoryConfig; @@ -134,6 +138,7 @@ export { type EvalContext, type FileContent, type KnowledgeBaseInfo, + type TopicReferenceItem, type UserMemoryData, } from '@lobechat/context-engine'; export type { PageContentContext } from '@lobechat/prompts'; diff --git a/src/services/chat/mecha/contextEngineering.ts b/src/services/chat/mecha/contextEngineering.ts index ab011108e0..ea1cdda8be 100644 --- a/src/services/chat/mecha/contextEngineering.ts +++ b/src/services/chat/mecha/contextEngineering.ts @@ -16,7 +16,7 @@ import type { ToolDiscoveryConfig, UserMemoryData, } from '@lobechat/context-engine'; -import { MessagesEngine } from '@lobechat/context-engine'; +import { MessagesEngine, resolveTopicReferences } from '@lobechat/context-engine'; import { historySummaryPrompt } from '@lobechat/prompts'; import type { OpenAIChatMessage, @@ -35,6 +35,7 @@ import { getChatGroupStoreState } from '@/store/agentGroup'; import { agentGroupSelectors } from '@/store/agentGroup/selectors'; import { getAiInfraStoreState } from '@/store/aiInfra'; import { getChatStoreState } from '@/store/chat'; +import { topicSelectors } from '@/store/chat/selectors'; import { getToolStoreState } from '@/store/tool'; import { builtinToolSelectors, @@ -515,6 +516,23 @@ export const contextEngineering = async ({ log('mentionedAgents injected: %d agents', initialContext!.mentionedAgents!.length); } + // Resolve topic references from messages containing tags + const topicReferences = await resolveTopicReferences( + messages, + async (topicId: string) => { + const topic = topicSelectors.getTopicById(topicId)(getChatStoreState()); + return topic ?? null; + }, + async (topicId: string) => { + const { messageService } = await import('@/services/message'); + const msgs = await messageService.getMessages({ agentId, groupId, topicId }); + return msgs.map((m) => ({ + content: typeof m.content === 'string' ? m.content : '', + role: m.role, + })); + }, + ); + // Create MessagesEngine with injected dependencies const engine = new MessagesEngine({ // Agent configuration @@ -582,6 +600,7 @@ export const contextEngineering = async ({ ...((isAgentManagementEnabled || hasMentionedAgents) && { agentManagementContext }), ...(agentGroup && { agentGroup }), ...(gtdConfig && { gtd: gtdConfig }), + ...(topicReferences && topicReferences.length > 0 && { topicReferences }), }); log('Input messages count: %d', messages.length);