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);