feat(context-engine): inject referenced topic context into last user message (#13104)

*  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 <tukon479@gmail.com>

*  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 <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-03-18 21:58:41 +08:00
committed by GitHub
parent ac29897d72
commit 465c9699e7
15 changed files with 871 additions and 2 deletions

View File

@@ -4,3 +4,5 @@ export * from './skills';
export * from './tools';
// Messages Engine
export * from './messages';
// Topic Reference
export * from './topicReference';

View File

@@ -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
// =============================================

View File

@@ -18,6 +18,7 @@ export type {
ToolDiscoveryConfig,
ToolDiscoveryMeta,
ToolsConfig,
TopicReferenceItem,
UIChatMessage,
UserMemoryActivityItem,
UserMemoryConfig,

View File

@@ -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';

View File

@@ -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: '<refer_topic name="My Topic" id="topic-1" />' },
]);
expect(result).toEqual([{ topicId: 'topic-1', topicTitle: 'My Topic' }]);
});
it('should parse refer_topic tags with id before name', () => {
const result = parseReferTopicTags([
{ content: '<refer_topic id="topic-2" name="Other Topic" />' },
]);
expect(result).toEqual([{ topicId: 'topic-2', topicTitle: 'Other Topic' }]);
});
it('should deduplicate topic IDs across messages', () => {
const result = parseReferTopicTags([
{ content: '<refer_topic name="A" id="topic-1" />' },
{ content: '<refer_topic name="A" id="topic-1" />' },
]);
expect(result).toHaveLength(1);
expect(result[0].topicId).toBe('topic-1');
});
it('should parse multiple different topics', () => {
const result = parseReferTopicTags([
{ content: '<refer_topic name="A" id="t1" />\n<refer_topic name="B" id="t2" />' },
]);
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 \\<refer\\_topic name="新对话开始" id="tpc\\_867aVIS5Av2G" />' },
]);
expect(result).toEqual([{ topicId: 'tpc_867aVIS5Av2G', topicTitle: '新对话开始' }]);
});
it('should parse markdown-escaped tags at start of content', () => {
const result = parseReferTopicTags([
{ content: '\\<refer\\_topic name="Greeting Message" id="tpc\\_EH0s0KrwpJN2" />讲了啥' },
]);
expect(result).toEqual([{ topicId: 'tpc_EH0s0KrwpJN2', topicTitle: 'Greeting Message' }]);
});
it('should parse mixed escaped and non-escaped tags', () => {
const result = parseReferTopicTags([
{ content: '\\<refer\\_topic name="A" id="tpc\\_1" />' },
{ content: '<refer_topic name="B" id="tpc_2" />' },
]);
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: '<refer_topic name="UI Title" id="topic-1" />' }],
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: '<refer_topic name="Fallback" id="topic-2" />' }],
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: '<refer_topic name="Parsed Title" id="topic-3" />' }],
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: '<refer_topic name="Error Topic" id="topic-err" />' }],
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: '<refer_topic name="A" id="t1" />\n<refer_topic name="B" id="t2" />' }],
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: '<refer_topic name="T" id="t1" />' }],
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: '<refer_topic name="T" id="t1" />' }],
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: '<refer_topic name="T" id="t1" />' }],
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: '<refer_topic name="T" id="t1" />' }],
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: '<refer_topic name="T" id="t1" />' }],
lookup,
lookupMessages,
);
expect(result![0].summary).toBeUndefined();
expect(result![0].recentMessages).toBeUndefined();
});
});

View File

@@ -0,0 +1,6 @@
export type {
ParsedTopicReference,
TopicLookupResult,
TopicMessageItem,
} from './resolveTopicReferences';
export { parseReferTopicTags, resolveTopicReferences } from './resolveTopicReferences';

View File

@@ -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 <refer_topic> tags from message contents
*/
export function parseReferTopicTags(
messages: Array<{ content: string | unknown }>,
): ParsedTopicReference[] {
const topicIds = new Set<string>();
const topicNames = new Map<string, string>();
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 = /<refer_topic\s([^>]*)>/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<TopicLookupResult | null | undefined>,
lookupMessages?: (topicId: string) => Promise<TopicMessageItem[]>,
): Promise<TopicReferenceItem[] | undefined> {
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;
}

View File

@@ -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[] = [
'<referred_topics hint="The following are brief summaries or excerpts of referenced topics. For complete conversation history, use the getTopicContext tool.">',
];
for (const item of withContext) {
const title = item.topicTitle ? ` title="${item.topicTitle}"` : '';
const type = item.summary ? 'summary' : 'recent_messages';
lines.push(`<topic id="${item.topicId}"${title} type="${type}">`);
if (item.summary) {
lines.push(item.summary);
} else {
lines.push(formatRecentMessages(item.recentMessages!));
}
lines.push('</topic>');
}
if (withoutContext.length > 0) {
lines.push(
'<pending_topics hint="No context available for the following topics. Use the getTopicContext tool to retrieve their conversation history.">',
);
for (const item of withoutContext) {
const title = item.topicTitle ? ` title="${item.topicTitle}"` : '';
lines.push(`<topic id="${item.topicId}"${title} />`);
}
lines.push('</pending_topics>');
}
lines.push('</referred_topics>');
return lines.join('\n');
}
/**
* Topic Reference Context Injector
*
* When user messages contain <refer_topic> 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<PipelineContext> {
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);
}
}

View File

@@ -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: '<refer_topic name="React Hooks Discussion" id="topic-1" />\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('<referred_topics hint=');
expect(lastContent).toContain('getTopicContext tool');
expect(lastContent).toContain(
'<topic id="topic-1" title="React Hooks Discussion" type="summary">',
);
expect(lastContent).toContain('This topic discussed React hooks and state management.');
expect(lastContent).toContain('</referred_topics>');
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('<pending_topics');
expect(lastContent).toContain('getTopicContext');
expect(lastContent).toContain('<topic id="topic-no-summary" title="Old Chat" />');
expect(lastContent).not.toContain('</topic>');
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('<topic id="topic-a" title="Topic A" type="summary">');
expect(lastContent).toContain('Summary of topic A.');
expect(lastContent).toContain('<pending_topics');
expect(lastContent).toContain('<topic id="topic-b" title="Topic B" />');
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
<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
<existing_context>
some context
</existing_context>
<!-- END SYSTEM CONTEXT -->`,
id: 'user-1',
role: 'user',
},
]);
const result = await injector.process(context);
const content = result.messages[0].content as string;
expect(content.match(/<!-- SYSTEM CONTEXT \(NOT PART OF USER QUERY\) -->/g)).toHaveLength(1);
expect(content).toContain('<existing_context>');
expect(content).toContain('<topic_reference_context>');
});
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(
'<topic id="topic-recent" title="React Q&A" type="recent_messages">',
);
expect(lastContent).toContain('**User**: What is React?');
expect(lastContent).toContain('**Assistant**: React is a JS library.');
expect(lastContent).not.toContain('<pending_topics');
});
it('should skip injection when no user messages exist', async () => {
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();
});
});

View File

@@ -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';

View File

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

View File

@@ -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:
'<refer_topic name="Old topic" id="topic-abc" />\nHello\n<system_context>\n<context type="topic_reference_context">\n<referred_topics>...</referred_topics>\n</context>\n</system_context>',
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 <refer_topic> tags → topicReferences is undefined
const callArgs = engineSpy.mock.calls[0][0];
expect(callArgs).not.toHaveProperty('topicReferences');
});
});
});

View File

@@ -68,6 +68,7 @@ export const serverMessagesEngine = async ({
evalContext,
agentManagementContext,
pageContentContext,
topicReferences,
additionalVariables,
userTimezone,
}: ServerMessagesEngineParams): Promise<OpenAIChatMessage[]> => {
@@ -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 }),

View File

@@ -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';

View File

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