mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ 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:
@@ -4,3 +4,5 @@ export * from './skills';
|
|||||||
export * from './tools';
|
export * from './tools';
|
||||||
// Messages Engine
|
// Messages Engine
|
||||||
export * from './messages';
|
export * from './messages';
|
||||||
|
// Topic Reference
|
||||||
|
export * from './topicReference';
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
SystemRoleInjector,
|
SystemRoleInjector,
|
||||||
ToolDiscoveryProvider,
|
ToolDiscoveryProvider,
|
||||||
ToolSystemRoleProvider,
|
ToolSystemRoleProvider,
|
||||||
|
TopicReferenceContextInjector,
|
||||||
UserMemoryInjector,
|
UserMemoryInjector,
|
||||||
} from '../../providers';
|
} from '../../providers';
|
||||||
import type { ContextProcessor } from '../../types';
|
import type { ContextProcessor } from '../../types';
|
||||||
@@ -147,6 +148,7 @@ export class MessagesEngine {
|
|||||||
initialContext,
|
initialContext,
|
||||||
stepContext,
|
stepContext,
|
||||||
pageContentContext,
|
pageContentContext,
|
||||||
|
topicReferences,
|
||||||
enableSystemDate,
|
enableSystemDate,
|
||||||
timezone,
|
timezone,
|
||||||
} = this.params;
|
} = this.params;
|
||||||
@@ -316,6 +318,16 @@ export class MessagesEngine {
|
|||||||
// 17. GTD Todo injection (conditionally added, at end of last user message)
|
// 17. GTD Todo injection (conditionally added, at end of last user message)
|
||||||
...(isGTDTodoEnabled ? [new GTDTodoInjector({ enabled: true, todos: gtd.todos })] : []),
|
...(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
|
// Phase 4: Message Transformation
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type {
|
|||||||
ToolDiscoveryConfig,
|
ToolDiscoveryConfig,
|
||||||
ToolDiscoveryMeta,
|
ToolDiscoveryMeta,
|
||||||
ToolsConfig,
|
ToolsConfig,
|
||||||
|
TopicReferenceItem,
|
||||||
UIChatMessage,
|
UIChatMessage,
|
||||||
UserMemoryActivityItem,
|
UserMemoryActivityItem,
|
||||||
UserMemoryConfig,
|
UserMemoryConfig,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { GTDPlan } from '../../providers/GTDPlanInjector';
|
|||||||
import type { GTDTodoList } from '../../providers/GTDTodoInjector';
|
import type { GTDTodoList } from '../../providers/GTDTodoInjector';
|
||||||
import type { SkillMeta } from '../../providers/SkillContextProvider';
|
import type { SkillMeta } from '../../providers/SkillContextProvider';
|
||||||
import type { ToolDiscoveryMeta } from '../../providers/ToolDiscoveryProvider';
|
import type { ToolDiscoveryMeta } from '../../providers/ToolDiscoveryProvider';
|
||||||
|
import type { TopicReferenceItem } from '../../providers/TopicReferenceContextInjector';
|
||||||
import type { PipelineContextMetadata } from '../../types';
|
import type { PipelineContextMetadata } from '../../types';
|
||||||
import type { LobeToolManifest } from '../tools/types';
|
import type { LobeToolManifest } from '../tools/types';
|
||||||
|
|
||||||
@@ -281,6 +282,10 @@ export interface MessagesEngineParams {
|
|||||||
/** User memory configuration */
|
/** User memory configuration */
|
||||||
userMemory?: UserMemoryConfig;
|
userMemory?: UserMemoryConfig;
|
||||||
|
|
||||||
|
// ========== Topic References ==========
|
||||||
|
/** Topic reference summaries to inject into last user message */
|
||||||
|
topicReferences?: TopicReferenceItem[];
|
||||||
|
|
||||||
// ========== Page Editor context ==========
|
// ========== Page Editor context ==========
|
||||||
/**
|
/**
|
||||||
* Initial context captured at operation start (frontend runtime usage)
|
* 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 GTDTodoItem, type GTDTodoList } from '../../providers/GTDTodoInjector';
|
||||||
export { type SkillMeta } from '../../providers/SkillContextProvider';
|
export { type SkillMeta } from '../../providers/SkillContextProvider';
|
||||||
export { type ToolDiscoveryMeta } from '../../providers/ToolDiscoveryProvider';
|
export { type ToolDiscoveryMeta } from '../../providers/ToolDiscoveryProvider';
|
||||||
|
export { type TopicReferenceItem } from '../../providers/TopicReferenceContextInjector';
|
||||||
export { type OpenAIChatMessage, type UIChatMessage } from '@/types/index';
|
export { type OpenAIChatMessage, type UIChatMessage } from '@/types/index';
|
||||||
export { type FileContent, type KnowledgeBaseInfo } from '@lobechat/prompts';
|
export { type FileContent, type KnowledgeBaseInfo } from '@lobechat/prompts';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type {
|
||||||
|
ParsedTopicReference,
|
||||||
|
TopicLookupResult,
|
||||||
|
TopicMessageItem,
|
||||||
|
} from './resolveTopicReferences';
|
||||||
|
export { parseReferTopicTags, resolveTopicReferences } from './resolveTopicReferences';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ export { SystemDateProvider } from './SystemDateProvider';
|
|||||||
export { SystemRoleInjector } from './SystemRoleInjector';
|
export { SystemRoleInjector } from './SystemRoleInjector';
|
||||||
export { ToolDiscoveryProvider } from './ToolDiscoveryProvider';
|
export { ToolDiscoveryProvider } from './ToolDiscoveryProvider';
|
||||||
export { ToolSystemRoleProvider } from './ToolSystemRole';
|
export { ToolSystemRoleProvider } from './ToolSystemRole';
|
||||||
|
export { TopicReferenceContextInjector } from './TopicReferenceContextInjector';
|
||||||
export { UserMemoryInjector } from './UserMemoryInjector';
|
export { UserMemoryInjector } from './UserMemoryInjector';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
@@ -58,4 +59,8 @@ export type { SystemDateProviderConfig } from './SystemDateProvider';
|
|||||||
export type { SystemRoleInjectorConfig } from './SystemRoleInjector';
|
export type { SystemRoleInjectorConfig } from './SystemRoleInjector';
|
||||||
export type { ToolDiscoveryMeta, ToolDiscoveryProviderConfig } from './ToolDiscoveryProvider';
|
export type { ToolDiscoveryMeta, ToolDiscoveryProviderConfig } from './ToolDiscoveryProvider';
|
||||||
export type { ToolSystemRoleConfig } from './ToolSystemRole';
|
export type { ToolSystemRoleConfig } from './ToolSystemRole';
|
||||||
|
export type {
|
||||||
|
TopicReferenceContextInjectorConfig,
|
||||||
|
TopicReferenceItem,
|
||||||
|
} from './TopicReferenceContextInjector';
|
||||||
export type { MemoryContext, UserMemoryInjectorConfig } from './UserMemoryInjector';
|
export type { MemoryContext, UserMemoryInjectorConfig } from './UserMemoryInjector';
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type LobeToolManifest,
|
type LobeToolManifest,
|
||||||
type OperationToolSet,
|
type OperationToolSet,
|
||||||
type ResolvedToolSet,
|
type ResolvedToolSet,
|
||||||
|
resolveTopicReferences,
|
||||||
ToolNameResolver,
|
ToolNameResolver,
|
||||||
ToolResolver,
|
ToolResolver,
|
||||||
} from '@lobechat/context-engine';
|
} from '@lobechat/context-engine';
|
||||||
@@ -24,7 +25,8 @@ import { type ChatToolPayload, type MessageToolCall, type UIChatMessage } from '
|
|||||||
import { serializePartsForStorage } from '@lobechat/utils';
|
import { serializePartsForStorage } from '@lobechat/utils';
|
||||||
import debug from 'debug';
|
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 { type LobeChatDatabase } from '@/database/type';
|
||||||
import { serverMessagesEngine } from '@/server/modules/Mecha/ContextEngineering';
|
import { serverMessagesEngine } from '@/server/modules/Mecha/ContextEngineering';
|
||||||
import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/types';
|
import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/types';
|
||||||
@@ -178,6 +180,33 @@ export const createRuntimeExecutors = (
|
|||||||
if (agentConfig) {
|
if (agentConfig) {
|
||||||
const { LOBE_DEFAULT_MODEL_LIST } = await import('model-bank');
|
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 = {
|
const contextEngineInput = {
|
||||||
additionalVariables: state.metadata?.deviceSystemInfo,
|
additionalVariables: state.metadata?.deviceSystemInfo,
|
||||||
userTimezone: ctx.userTimezone,
|
userTimezone: ctx.userTimezone,
|
||||||
@@ -235,6 +264,9 @@ export const createRuntimeExecutors = (
|
|||||||
...(state.metadata?.skillMetas?.length && {
|
...(state.metadata?.skillMetas?.length && {
|
||||||
skillsConfig: { enabledSkills: state.metadata.skillMetas },
|
skillsConfig: { enabledSkills: state.metadata.skillMetas },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Topic reference summaries
|
||||||
|
...(topicReferences && { topicReferences }),
|
||||||
};
|
};
|
||||||
|
|
||||||
processedMessages = await serverMessagesEngine(contextEngineInput);
|
processedMessages = await serverMessagesEngine(contextEngineInput);
|
||||||
|
|||||||
@@ -1100,6 +1100,61 @@ describe('RuntimeExecutors', () => {
|
|||||||
name: 'Enabled KB',
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const serverMessagesEngine = async ({
|
|||||||
evalContext,
|
evalContext,
|
||||||
agentManagementContext,
|
agentManagementContext,
|
||||||
pageContentContext,
|
pageContentContext,
|
||||||
|
topicReferences,
|
||||||
additionalVariables,
|
additionalVariables,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
}: ServerMessagesEngineParams): Promise<OpenAIChatMessage[]> => {
|
}: ServerMessagesEngineParams): Promise<OpenAIChatMessage[]> => {
|
||||||
@@ -140,6 +141,9 @@ export const serverMessagesEngine = async ({
|
|||||||
// Skills configuration
|
// Skills configuration
|
||||||
...(skillsConfig?.enabledSkills && skillsConfig.enabledSkills.length > 0 && { skillsConfig }),
|
...(skillsConfig?.enabledSkills && skillsConfig.enabledSkills.length > 0 && { skillsConfig }),
|
||||||
|
|
||||||
|
// Topic references
|
||||||
|
...(topicReferences && topicReferences.length > 0 && { topicReferences }),
|
||||||
|
|
||||||
// Extended contexts
|
// Extended contexts
|
||||||
...(agentBuilderContext && { agentBuilderContext }),
|
...(agentBuilderContext && { agentBuilderContext }),
|
||||||
...(discordContext && { discordContext }),
|
...(discordContext && { discordContext }),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
KnowledgeBaseInfo,
|
KnowledgeBaseInfo,
|
||||||
LobeToolManifest,
|
LobeToolManifest,
|
||||||
SkillMeta,
|
SkillMeta,
|
||||||
|
TopicReferenceItem,
|
||||||
UserMemoryData,
|
UserMemoryData,
|
||||||
} from '@lobechat/context-engine';
|
} from '@lobechat/context-engine';
|
||||||
import type { PageContentContext } from '@lobechat/prompts';
|
import type { PageContentContext } from '@lobechat/prompts';
|
||||||
@@ -120,6 +121,9 @@ export interface ServerMessagesEngineParams {
|
|||||||
// ========== Tools ==========
|
// ========== Tools ==========
|
||||||
/** Tools configuration */
|
/** Tools configuration */
|
||||||
toolsConfig?: ServerToolsConfig;
|
toolsConfig?: ServerToolsConfig;
|
||||||
|
// ========== Topic References ==========
|
||||||
|
/** Topic reference summaries to inject into last user message */
|
||||||
|
topicReferences?: TopicReferenceItem[];
|
||||||
// ========== User memory ==========
|
// ========== User memory ==========
|
||||||
/** User memory configuration */
|
/** User memory configuration */
|
||||||
userMemory?: ServerUserMemoryConfig;
|
userMemory?: ServerUserMemoryConfig;
|
||||||
@@ -134,6 +138,7 @@ export {
|
|||||||
type EvalContext,
|
type EvalContext,
|
||||||
type FileContent,
|
type FileContent,
|
||||||
type KnowledgeBaseInfo,
|
type KnowledgeBaseInfo,
|
||||||
|
type TopicReferenceItem,
|
||||||
type UserMemoryData,
|
type UserMemoryData,
|
||||||
} from '@lobechat/context-engine';
|
} from '@lobechat/context-engine';
|
||||||
export type { PageContentContext } from '@lobechat/prompts';
|
export type { PageContentContext } from '@lobechat/prompts';
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
ToolDiscoveryConfig,
|
ToolDiscoveryConfig,
|
||||||
UserMemoryData,
|
UserMemoryData,
|
||||||
} from '@lobechat/context-engine';
|
} from '@lobechat/context-engine';
|
||||||
import { MessagesEngine } from '@lobechat/context-engine';
|
import { MessagesEngine, resolveTopicReferences } from '@lobechat/context-engine';
|
||||||
import { historySummaryPrompt } from '@lobechat/prompts';
|
import { historySummaryPrompt } from '@lobechat/prompts';
|
||||||
import type {
|
import type {
|
||||||
OpenAIChatMessage,
|
OpenAIChatMessage,
|
||||||
@@ -35,6 +35,7 @@ import { getChatGroupStoreState } from '@/store/agentGroup';
|
|||||||
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
|
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
|
||||||
import { getAiInfraStoreState } from '@/store/aiInfra';
|
import { getAiInfraStoreState } from '@/store/aiInfra';
|
||||||
import { getChatStoreState } from '@/store/chat';
|
import { getChatStoreState } from '@/store/chat';
|
||||||
|
import { topicSelectors } from '@/store/chat/selectors';
|
||||||
import { getToolStoreState } from '@/store/tool';
|
import { getToolStoreState } from '@/store/tool';
|
||||||
import {
|
import {
|
||||||
builtinToolSelectors,
|
builtinToolSelectors,
|
||||||
@@ -515,6 +516,23 @@ export const contextEngineering = async ({
|
|||||||
log('mentionedAgents injected: %d agents', initialContext!.mentionedAgents!.length);
|
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
|
// Create MessagesEngine with injected dependencies
|
||||||
const engine = new MessagesEngine({
|
const engine = new MessagesEngine({
|
||||||
// Agent configuration
|
// Agent configuration
|
||||||
@@ -582,6 +600,7 @@ export const contextEngineering = async ({
|
|||||||
...((isAgentManagementEnabled || hasMentionedAgents) && { agentManagementContext }),
|
...((isAgentManagementEnabled || hasMentionedAgents) && { agentManagementContext }),
|
||||||
...(agentGroup && { agentGroup }),
|
...(agentGroup && { agentGroup }),
|
||||||
...(gtdConfig && { gtd: gtdConfig }),
|
...(gtdConfig && { gtd: gtdConfig }),
|
||||||
|
...(topicReferences && topicReferences.length > 0 && { topicReferences }),
|
||||||
});
|
});
|
||||||
|
|
||||||
log('Input messages count: %d', messages.length);
|
log('Input messages count: %d', messages.length);
|
||||||
|
|||||||
Reference in New Issue
Block a user