💄 style: improve discord interaction (#12573)

* improve discord interaction

* improve discord interaction

* update

* update Message engine

* add test

* update vercel route
This commit is contained in:
Arvin Xu
2026-03-02 16:13:21 +08:00
committed by GitHub
parent dc6d5cf489
commit 46c9cb3b03
19 changed files with 653 additions and 91 deletions
@@ -22,13 +22,14 @@ import {
} from '../../processors';
import {
AgentBuilderContextInjector,
AgentManagementContextInjector,
DiscordContextProvider,
EvalContextSystemInjector,
ForceFinishSummaryInjector,
AgentManagementContextInjector,
GTDPlanInjector,
GTDTodoInjector,
GroupAgentBuilderContextInjector,
GroupContextInjector,
GTDPlanInjector,
GTDTodoInjector,
HistorySummaryProvider,
KnowledgeInjector,
PageEditorContextInjector,
@@ -131,6 +132,7 @@ export class MessagesEngine {
variableGenerators,
fileContext,
agentBuilderContext,
discordContext,
evalContext,
agentManagementContext,
groupAgentBuilderContext,
@@ -198,6 +200,11 @@ export class MessagesEngine {
systemPrompt: agentGroup?.systemPrompt,
}),
// 5.5. Discord context injection (channel/guild info for Discord bot scenarios)
...(discordContext
? [new DiscordContextProvider({ context: discordContext, enabled: true })]
: []),
// 6. GTD Plan injection (conditionally added, after user memory, before knowledge)
...(isGTDPlanEnabled ? [new GTDPlanInjector({ enabled: true, plan: gtd.plan })] : []),
@@ -246,13 +253,13 @@ export class MessagesEngine {
// 12. Tool system role injection (conditionally added)
...(toolsConfig?.manifests && toolsConfig.manifests.length > 0
? [
new ToolSystemRoleProvider({
isCanUseFC: capabilities?.isCanUseFC || (() => true),
manifests: toolsConfig.manifests,
model,
provider,
}),
]
new ToolSystemRoleProvider({
isCanUseFC: capabilities?.isCanUseFC || (() => true),
manifests: toolsConfig.manifests,
model,
provider,
}),
]
: []),
// 13. History summary injection
@@ -272,15 +279,15 @@ export class MessagesEngine {
? pageContentContext
: initialContext?.pageEditor
? {
markdown: initialContext.pageEditor.markdown,
metadata: {
charCount: initialContext.pageEditor.metadata.charCount,
lineCount: initialContext.pageEditor.metadata.lineCount,
title: initialContext.pageEditor.metadata.title,
},
// Use latest XML from stepContext if available, otherwise fallback to initial XML
xml: stepContext?.stepPageEditor?.xml || initialContext.pageEditor.xml,
}
markdown: initialContext.pageEditor.markdown,
metadata: {
charCount: initialContext.pageEditor.metadata.charCount,
lineCount: initialContext.pageEditor.metadata.lineCount,
title: initialContext.pageEditor.metadata.title,
},
// Use latest XML from stepContext if available, otherwise fallback to initial XML
xml: stepContext?.stepPageEditor?.xml || initialContext.pageEditor.xml,
}
: undefined,
}),
@@ -321,26 +328,26 @@ export class MessagesEngine {
// This must be BEFORE GroupRoleTransformProcessor so we filter based on original agentId/tools
...(isAgentGroupEnabled && agentGroup.agentMap && agentGroup.currentAgentId
? [
new GroupOrchestrationFilterProcessor({
agentMap: Object.fromEntries(
Object.entries(agentGroup.agentMap).map(([id, info]) => [id, { role: info.role }]),
),
currentAgentId: agentGroup.currentAgentId,
// Only enabled when current agent is NOT supervisor (supervisor needs to see orchestration history)
enabled: agentGroup.currentAgentRole !== 'supervisor',
}),
]
new GroupOrchestrationFilterProcessor({
agentMap: Object.fromEntries(
Object.entries(agentGroup.agentMap).map(([id, info]) => [id, { role: info.role }]),
),
currentAgentId: agentGroup.currentAgentId,
// Only enabled when current agent is NOT supervisor (supervisor needs to see orchestration history)
enabled: agentGroup.currentAgentRole !== 'supervisor',
}),
]
: []),
// 26. Group role transform (convert other agents' messages to user role with speaker tags)
// This must be BEFORE ToolCallProcessor so other agents' tool messages are converted first
...(isAgentGroupEnabled && agentGroup.currentAgentId
? [
new GroupRoleTransformProcessor({
agentMap: agentGroup.agentMap!,
currentAgentId: agentGroup.currentAgentId,
}),
]
new GroupRoleTransformProcessor({
agentMap: agentGroup.agentMap!,
currentAgentId: agentGroup.currentAgentId,
}),
]
: []),
// =============================================
@@ -6,10 +6,11 @@ import type { OpenAIChatMessage, UIChatMessage } from '@/types/index';
import type { AgentInfo } from '../../processors/GroupRoleTransform';
import type { AgentBuilderContext } from '../../providers/AgentBuilderContextInjector';
import type { AgentManagementContext } from '../../providers/AgentManagementContextInjector';
import type { DiscordContext } from '../../providers/DiscordContextProvider';
import type { EvalContext } from '../../providers/EvalContextSystemInjector';
import type { GroupAgentBuilderContext } from '../../providers/GroupAgentBuilderContextInjector';
import type { GroupMemberInfo } from '../../providers/GroupContextInjector';
import type { AgentManagementContext } from '../../providers/AgentManagementContextInjector';
import type { GTDPlan } from '../../providers/GTDPlanInjector';
import type { GTDTodoList } from '../../providers/GTDTodoInjector';
import type { SkillMeta } from '../../providers/SkillContextProvider';
@@ -244,6 +245,8 @@ export interface MessagesEngineParams {
// ========== Extended contexts (both frontend and backend) ==========
/** Agent Builder context */
agentBuilderContext?: AgentBuilderContext;
/** Discord context for injecting channel/guild info into system injection message */
discordContext?: DiscordContext;
/** Eval context for injecting environment prompts into system message */
evalContext?: EvalContext;
/** Agent Management context */
@@ -302,8 +305,9 @@ export interface MessagesEngineResult {
export { type AgentInfo } from '../../processors/GroupRoleTransform';
export { type AgentBuilderContext } from '../../providers/AgentBuilderContextInjector';
export { type EvalContext } from '../../providers/EvalContextSystemInjector';
export { type AgentManagementContext } from '../../providers/AgentManagementContextInjector';
export { type DiscordContext } from '../../providers/DiscordContextProvider';
export { type EvalContext } from '../../providers/EvalContextSystemInjector';
export { type GroupAgentBuilderContext } from '../../providers/GroupAgentBuilderContextInjector';
export { type GTDPlan } from '../../providers/GTDPlanInjector';
export { type GTDTodoItem, type GTDTodoList } from '../../providers/GTDTodoInjector';
@@ -0,0 +1,46 @@
import type { DiscordChannelInfo, DiscordGuildInfo } from '@lobechat/prompts';
import { formatDiscordContext } from '@lobechat/prompts';
import debug from 'debug';
import { BaseFirstUserContentProvider } from '../base/BaseFirstUserContentProvider';
import type { ProcessorOptions } from '../types';
const log = debug('context-engine:provider:DiscordContextProvider');
export interface DiscordContext {
channel?: DiscordChannelInfo;
guild?: DiscordGuildInfo;
}
export interface DiscordContextProviderConfig {
context?: DiscordContext;
enabled?: boolean;
}
export class DiscordContextProvider extends BaseFirstUserContentProvider {
readonly name = 'DiscordContextProvider';
constructor(
private config: DiscordContextProviderConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected buildContent(): string | null {
if (!this.config.enabled || !this.config.context) {
log('Discord context injection disabled or no context, skipping');
return null;
}
const { guild, channel } = this.config.context;
if (!guild && !channel) {
log('No guild or channel info, skipping');
return null;
}
log('Discord context prepared for injection');
return formatDiscordContext({ channel, guild });
}
}
@@ -0,0 +1,236 @@
import { describe, expect, it } from 'vitest';
import type { PipelineContext } from '../../types';
import { DiscordContextProvider } from '../DiscordContextProvider';
describe('DiscordContextProvider', () => {
const createContext = (messages: any[]): PipelineContext => ({
initialState: { messages: [] },
isAborted: false,
messages,
metadata: {},
});
// Helper: extract injected content string from result
const getInjectedContent = (result: PipelineContext, index = 0): string =>
result.messages[index].content as string;
describe('Basic Scenarios', () => {
it('should inject discord context before first user message', async () => {
const provider = new DiscordContextProvider({
context: {
channel: { id: '789', name: 'general', topic: 'General discussion', type: 0 },
guild: { id: '123456' },
},
enabled: true,
});
const input: any[] = [
{ content: 'You are a helpful assistant.', role: 'system' },
{ content: 'Hello', role: 'user' },
];
const result = await provider.process(createContext(input));
expect(result.messages).toHaveLength(3);
expect(result.messages[0].content).toBe('You are a helpful assistant.');
expect(result.messages[1].role).toBe('user');
expect(getInjectedContent(result, 1)).toBe(`<discord_context>
<guild id="123456" />
<channel id="789" name="general" type="0" topic="General discussion" />
</discord_context>`);
expect(result.messages[2].content).toBe('Hello');
});
it('should inject guild with name', async () => {
const provider = new DiscordContextProvider({
context: {
channel: { id: '789', name: 'dev' },
guild: { id: '123', name: 'My Server' },
},
enabled: true,
});
const input: any[] = [{ content: 'Hi', role: 'user' }];
const result = await provider.process(createContext(input));
expect(getInjectedContent(result)).toBe(`<discord_context>
<guild id="123" name="My Server" />
<channel id="789" name="dev" />
</discord_context>`);
});
it('should skip injection when disabled', async () => {
const provider = new DiscordContextProvider({
context: {
channel: { id: '789', name: 'general' },
guild: { id: '123' },
},
enabled: false,
});
const input: any[] = [
{ content: 'System', role: 'system' },
{ content: 'Hello', role: 'user' },
];
const result = await provider.process(createContext(input));
expect(result.messages).toHaveLength(2);
});
it('should skip injection when context is undefined', async () => {
const provider = new DiscordContextProvider({ enabled: true });
const input: any[] = [{ content: 'Hello', role: 'user' }];
const result = await provider.process(createContext(input));
expect(result.messages).toHaveLength(1);
});
it('should skip injection when both guild and channel are undefined', async () => {
const provider = new DiscordContextProvider({
context: {},
enabled: true,
});
const input: any[] = [{ content: 'Hello', role: 'user' }];
const result = await provider.process(createContext(input));
expect(result.messages).toHaveLength(1);
});
});
describe('Partial Context', () => {
it('should inject only guild when channel is missing', async () => {
const provider = new DiscordContextProvider({
context: { guild: { id: '123', name: 'Server' } },
enabled: true,
});
const input: any[] = [{ content: 'Hello', role: 'user' }];
const result = await provider.process(createContext(input));
expect(result.messages).toHaveLength(2);
expect(getInjectedContent(result)).toBe(`<discord_context>
<guild id="123" name="Server" />
</discord_context>`);
});
it('should inject only channel when guild is missing', async () => {
const provider = new DiscordContextProvider({
context: { channel: { id: '789', name: 'general', type: 0 } },
enabled: true,
});
const input: any[] = [{ content: 'Hello', role: 'user' }];
const result = await provider.process(createContext(input));
expect(result.messages).toHaveLength(2);
expect(getInjectedContent(result)).toBe(`<discord_context>
<channel id="789" name="general" type="0" />
</discord_context>`);
});
it('should handle channel with only id', async () => {
const provider = new DiscordContextProvider({
context: {
channel: { id: '789' },
guild: { id: '123' },
},
enabled: true,
});
const input: any[] = [{ content: 'Hello', role: 'user' }];
const result = await provider.process(createContext(input));
expect(getInjectedContent(result)).toBe(`<discord_context>
<guild id="123" />
<channel id="789" />
</discord_context>`);
});
});
describe('Attribute Handling', () => {
it('should include topic when provided', async () => {
const provider = new DiscordContextProvider({
context: {
channel: { id: '789', topic: 'Bug reports only' },
guild: { id: '123' },
},
enabled: true,
});
const input: any[] = [{ content: 'Hello', role: 'user' }];
const result = await provider.process(createContext(input));
expect(getInjectedContent(result)).toBe(`<discord_context>
<guild id="123" />
<channel id="789" topic="Bug reports only" />
</discord_context>`);
});
it('should include type=0 (falsy but valid)', async () => {
const provider = new DiscordContextProvider({
context: {
channel: { id: '789', type: 0 },
guild: { id: '123' },
},
enabled: true,
});
const input: any[] = [{ content: 'Hello', role: 'user' }];
const result = await provider.process(createContext(input));
expect(getInjectedContent(result)).toBe(`<discord_context>
<guild id="123" />
<channel id="789" type="0" />
</discord_context>`);
});
});
describe('Message Consolidation', () => {
it('should append to existing system injection message', async () => {
const provider = new DiscordContextProvider({
context: {
channel: { id: '789', name: 'general' },
guild: { id: '123' },
},
enabled: true,
});
const input: any[] = [
{ content: 'System role', role: 'system' },
{
content: 'Previous injected content',
meta: { systemInjection: true },
role: 'user',
},
{ content: 'Hello', role: 'user' },
];
const result = await provider.process(createContext(input));
// Should NOT create a new message — should append to existing injection
expect(result.messages).toHaveLength(3);
expect(getInjectedContent(result, 1)).toBe(`Previous injected content
<discord_context>
<guild id="123" />
<channel id="789" name="general" />
</discord_context>`);
});
it('should skip when no user message exists', async () => {
const provider = new DiscordContextProvider({
context: {
channel: { id: '789' },
guild: { id: '123' },
},
enabled: true,
});
const input: any[] = [{ content: 'System only', role: 'system' }];
const result = await provider.process(createContext(input));
expect(result.messages).toHaveLength(1);
});
});
});
@@ -1,8 +1,9 @@
// Context Provider exports
export { AgentBuilderContextInjector } from './AgentBuilderContextInjector';
export { AgentManagementContextInjector } from './AgentManagementContextInjector';
export { DiscordContextProvider } from './DiscordContextProvider';
export { EvalContextSystemInjector } from './EvalContextSystemInjector';
export { ForceFinishSummaryInjector } from './ForceFinishSummaryInjector';
export { AgentManagementContextInjector } from './AgentManagementContextInjector';
export { GroupAgentBuilderContextInjector } from './GroupAgentBuilderContextInjector';
export { GroupContextInjector } from './GroupContextInjector';
export { GTDPlanInjector } from './GTDPlanInjector';
@@ -24,8 +25,6 @@ export type {
AgentBuilderContextInjectorConfig,
OfficialToolItem,
} from './AgentBuilderContextInjector';
export type { EvalContext, EvalContextSystemInjectorConfig } from './EvalContextSystemInjector';
export type { ForceFinishSummaryInjectorConfig } from './ForceFinishSummaryInjector';
export type {
AgentManagementContext,
AgentManagementContextInjectorConfig,
@@ -33,6 +32,9 @@ export type {
AvailablePluginInfo,
AvailableProviderInfo,
} from './AgentManagementContextInjector';
export type { DiscordContext, DiscordContextProviderConfig } from './DiscordContextProvider';
export type { EvalContext, EvalContextSystemInjectorConfig } from './EvalContextSystemInjector';
export type { ForceFinishSummaryInjectorConfig } from './ForceFinishSummaryInjector';
export type {
GroupAgentBuilderContext,
GroupAgentBuilderContextInjectorConfig,
@@ -0,0 +1,52 @@
export interface DiscordGuildInfo {
id: string;
name?: string;
}
export interface DiscordChannelInfo {
id: string;
name?: string;
topic?: string;
type?: number;
}
export interface FormatDiscordContextOptions {
channel?: DiscordChannelInfo;
guild?: DiscordGuildInfo;
}
/**
* Format Discord context into XML for system injection message.
*
* @example
* ```typescript
* const xml = formatDiscordContext({
* guild: { id: '123456', name: 'My Server' },
* channel: { id: '789', name: 'general', type: 0, topic: 'General discussion' },
* });
* // Returns:
* // <discord_context>
* // <guild id="123456" name="My Server" />
* // <channel id="789" name="general" type="0" topic="General discussion" />
* // </discord_context>
* ```
*/
export const formatDiscordContext = ({ guild, channel }: FormatDiscordContextOptions): string => {
const parts: string[] = [];
if (guild) {
const attrs = [`id="${guild.id}"`];
if (guild.name) attrs.push(`name="${guild.name}"`);
parts.push(` <guild ${attrs.join(' ')} />`);
}
if (channel) {
const attrs = [`id="${channel.id}"`];
if (channel.name) attrs.push(`name="${channel.name}"`);
if (channel.type !== undefined) attrs.push(`type="${channel.type}"`);
if (channel.topic) attrs.push(`topic="${channel.topic}"`);
parts.push(` <channel ${attrs.join(' ')} />`);
}
return `<discord_context>\n${parts.join('\n')}\n</discord_context>`;
};
+2
View File
@@ -2,6 +2,7 @@ export * from './agentBuilder';
export * from './agentGroup';
export * from './chatMessages';
export * from './compressContext';
export * from './discordContext';
export * from './files';
export * from './fileSystem';
export * from './groupChat';
@@ -11,6 +12,7 @@ export * from './messagesToText';
export * from './plugin';
export * from './search';
export * from './skills';
export * from './speaker';
export * from './systemRole';
export * from './toolDiscovery';
export * from './userMemory';
@@ -0,0 +1,48 @@
export interface SpeakerInfo {
avatar?: string;
id: string;
nickname?: string;
username?: string;
}
/**
* Format a speaker XML tag to prepend to a user message.
* Used in IM bot scenarios (Discord, Slack, etc.) to identify the message author.
*
* @example
* ```typescript
* const tag = formatSpeakerTag({
* id: '123456',
* username: 'john',
* nickname: 'John Doe',
* avatar: 'abc123',
* });
* // Returns: '<speaker id="123456" username="john" nickname="John Doe" avatar="abc123" />'
* ```
*/
export const formatSpeakerTag = (speaker: SpeakerInfo): string => {
const attrs = [`id="${speaker.id}"`];
if (speaker.username) attrs.push(`username="${speaker.username}"`);
if (speaker.nickname) attrs.push(`nickname="${speaker.nickname}"`);
if (speaker.avatar) attrs.push(`avatar="${speaker.avatar}"`);
return `<speaker ${attrs.join(' ')} />`;
};
/**
* Format a user message with a speaker tag prepended.
*
* @example
* ```typescript
* const prompt = formatSpeakerMessage(
* { id: '123456', username: 'john', nickname: 'John Doe' },
* 'Hello, how are you?',
* );
* // Returns:
* // '<speaker id="123456" username="john" nickname="John Doe" />\nHello, how are you?'
* ```
*/
export const formatSpeakerMessage = (speaker: SpeakerInfo, text: string): string => {
return `${formatSpeakerTag(speaker)}\n${text}`;
};
@@ -13,8 +13,6 @@ const log = debug('lobe-server:bot:gateway:cron:discord');
const GATEWAY_DURATION_MS = 600_000; // 10 minutes
const POLL_INTERVAL_MS = 30_000; // 30 seconds
export const maxDuration = 800;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function processConnectQueue(remainingMs: number): Promise<number> {
@@ -33,6 +33,7 @@ const TOOL_PRICING: Record<string, number> = {
export interface RuntimeExecutorContext {
agentConfig?: any;
discordContext?: any;
evalContext?: EvalContext;
fileService?: any;
messageModel: MessageModel;
@@ -155,6 +156,7 @@ export const createRuntimeExecutors = (
return info?.abilities?.vision ?? true;
},
},
discordContext: ctx.discordContext,
enableHistoryCount: agentConfig.chatConfig?.enableHistoryCount ?? undefined,
evalContext: ctx.evalContext,
forceFinish: state.forceFinish,
@@ -54,6 +54,7 @@ export const serverMessagesEngine = async ({
capabilities,
userMemory,
agentBuilderContext,
discordContext,
evalContext,
agentManagementContext,
pageContentContext,
@@ -118,6 +119,7 @@ export const serverMessagesEngine = async ({
// Extended contexts
...(agentBuilderContext && { agentBuilderContext }),
...(discordContext && { discordContext }),
...(evalContext && { evalContext }),
...(agentManagementContext && { agentManagementContext }),
...(pageContentContext && { pageContentContext }),
@@ -2,6 +2,7 @@
import type {
AgentBuilderContext,
AgentManagementContext,
DiscordContext,
EvalContext,
FileContent,
KnowledgeBaseInfo,
@@ -68,6 +69,8 @@ export interface ServerMessagesEngineParams {
// ========== Capability injection ==========
/** Model capability checkers */
capabilities?: ServerModelCapabilities;
/** Discord context for injecting channel/guild info */
discordContext?: DiscordContext;
// ========== Eval context ==========
/** Eval context for injecting environment prompts into system message */
evalContext?: EvalContext;
@@ -119,6 +122,7 @@ export interface ServerMessagesEngineParams {
export {
type AgentBuilderContext,
type AgentManagementContext,
type DiscordContext,
type EvalContext,
type FileContent,
type KnowledgeBaseInfo,
@@ -263,6 +263,7 @@ export class AgentRuntimeService {
completionWebhook,
stepWebhook,
webhookDelivery,
discordContext,
evalContext,
maxSteps,
} = params;
@@ -281,6 +282,7 @@ export class AgentRuntimeService {
metadata: {
agentConfig,
completionWebhook,
discordContext,
evalContext,
// need be removed
modelRuntimeConfig,
@@ -532,7 +534,9 @@ export class AgentRuntimeService {
let toolsCalling:
| Array<{ apiName: string; arguments?: string; identifier: string }>
| undefined;
let toolsResult: Array<{ apiName: string; identifier: string; output?: string }> | undefined;
let toolsResult:
| Array<{ apiName: string; identifier: string; isSuccess?: boolean; output?: string }>
| undefined;
let stepSummary: string;
if (phase === 'tool_result') {
@@ -545,6 +549,7 @@ export class AgentRuntimeService {
{
apiName,
identifier,
isSuccess: toolPayload?.isSuccess !== false,
output:
typeof output === 'string'
? output
@@ -558,21 +563,26 @@ export class AgentRuntimeService {
const nextPayload = stepResult.nextContext?.payload as any;
const toolCount = nextPayload?.toolCount || 0;
const rawToolResults = nextPayload?.toolResults || [];
const mappedResults: Array<{ apiName: string; identifier: string; output?: string }> =
rawToolResults.map((r: any) => {
const tc = r.toolCall;
const output = r.data;
return {
apiName: tc?.apiName || 'unknown',
identifier: tc?.identifier || 'unknown',
output:
typeof output === 'string'
? output
: output != null
? JSON.stringify(output)
: undefined,
};
});
const mappedResults: Array<{
apiName: string;
identifier: string;
isSuccess?: boolean;
output?: string;
}> = rawToolResults.map((r: any) => {
const tc = r.toolCall;
const output = r.data;
return {
apiName: tc?.apiName || 'unknown',
identifier: tc?.identifier || 'unknown',
isSuccess: r?.isSuccess !== false,
output:
typeof output === 'string'
? output
: output != null
? JSON.stringify(output)
: undefined,
};
});
toolsResult = mappedResults;
const toolNames = mappedResults.map((r) => `${r.identifier}/${r.apiName}`);
stepSummary = `[tools×${toolCount}] ${toolNames.join(', ')}`;
@@ -1166,6 +1176,7 @@ export class AgentRuntimeService {
// Create streaming executor context
const executorContext: RuntimeExecutorContext = {
agentConfig: metadata?.agentConfig,
discordContext: metadata?.discordContext,
evalContext: metadata?.evalContext,
messageModel: this.messageModel,
operationId,
+8 -1
View File
@@ -30,7 +30,12 @@ export interface StepPresentationData {
/** Tools the LLM decided to call (undefined if no tool calls) */
toolsCalling?: Array<{ apiName: string; arguments?: string; identifier: string }>;
/** Results from tool execution (only for call_tool steps) */
toolsResult?: Array<{ apiName: string; identifier: string; output?: string }>;
toolsResult?: Array<{
apiName: string;
identifier: string;
isSuccess?: boolean;
output?: string;
}>;
/** Cumulative total cost */
totalCost: number;
/** Cumulative input tokens */
@@ -129,6 +134,8 @@ export interface OperationCreationParams {
body?: Record<string, unknown>;
url: string;
};
/** Discord context for injecting channel/guild info into agent system message */
discordContext?: any;
evalContext?: any;
initialContext: AgentRuntimeContext;
initialMessages?: any[];
+4
View File
@@ -77,6 +77,8 @@ interface InternalExecAgentParams extends ExecAgentParams {
};
/** Cron job ID that triggered this execution (if trigger is 'cron') */
cronJobId?: string;
/** Discord context for injecting channel/guild info into agent system message */
discordContext?: any;
/** Eval context for injecting environment prompts into system message */
evalContext?: EvalContext;
/** Maximum steps for the agent operation */
@@ -167,6 +169,7 @@ export class AiAgentService {
appContext,
autoStart = true,
botContext,
discordContext,
existingMessageIds = [],
stepCallbacks,
stream,
@@ -516,6 +519,7 @@ export class AiAgentService {
},
autoStart,
completionWebhook,
discordContext,
evalContext,
initialContext,
initialMessages: allMessages,
+113 -12
View File
@@ -1,3 +1,4 @@
import { formatSpeakerMessage } from '@lobechat/prompts';
import type { ChatTopicBotContext } from '@lobechat/types';
import type { Message, SentMessage, Thread } from 'chat';
import { emoji } from 'chat';
@@ -63,6 +64,16 @@ async function safeReaction(fn: () => Promise<void>, label: string): Promise<voi
}
}
interface DiscordChannelContext {
channel: { id: string; name?: string; topic?: string; type?: number };
guild: { id: string };
}
interface ThreadState {
channelContext?: DiscordChannelContext;
topicId?: string;
}
interface BridgeHandlerOpts {
agentId: string;
botContext?: ChatTopicBotContext;
@@ -92,7 +103,7 @@ export class AgentBridgeService {
* Handle a new @mention — start a fresh conversation.
*/
async handleMention(
thread: Thread<{ topicId?: string }>,
thread: Thread<ThreadState>,
message: Message,
opts: BridgeHandlerOpts,
): Promise<void> {
@@ -113,6 +124,9 @@ export class AgentBridgeService {
await thread.subscribe();
await thread.startTyping();
// Fetch channel context for Discord context injection
const channelContext = await this.fetchChannelContext(thread);
const queueMode = isQueueAgentRuntimeEnabled();
try {
@@ -121,12 +135,13 @@ export class AgentBridgeService {
const { topicId } = await this.executeWithCallback(thread, message, {
agentId,
botContext,
channelContext,
trigger: 'bot',
});
// Persist topic mapping in thread state for follow-up messages
// Persist topic mapping and channel context in thread state for follow-up messages
if (topicId) {
await thread.setState({ topicId });
await thread.setState({ channelContext, topicId });
log('handleMention: stored topicId=%s in thread=%s state', topicId, thread.id);
}
} catch (error) {
@@ -145,7 +160,7 @@ export class AgentBridgeService {
* Handle a follow-up message inside a subscribed thread — multi-turn conversation.
*/
async handleSubscribedMessage(
thread: Thread<{ topicId?: string }>,
thread: Thread<ThreadState>,
message: Message,
opts: BridgeHandlerOpts,
): Promise<void> {
@@ -160,6 +175,9 @@ export class AgentBridgeService {
return this.handleMention(thread, message, { agentId, botContext });
}
// Read cached channel context from thread state
const channelContext = threadState?.channelContext;
const queueMode = isQueueAgentRuntimeEnabled();
// Immediate feedback: mark as received + show typing
@@ -174,6 +192,7 @@ export class AgentBridgeService {
await this.executeWithCallback(thread, message, {
agentId,
botContext,
channelContext,
topicId,
trigger: 'bot',
});
@@ -193,11 +212,12 @@ export class AgentBridgeService {
* Dispatch to queue-mode webhooks or local in-memory callbacks based on runtime mode.
*/
private async executeWithCallback(
thread: Thread<{ topicId?: string }>,
thread: Thread<ThreadState>,
userMessage: Message,
opts: {
agentId: string;
botContext?: ChatTopicBotContext;
channelContext?: DiscordChannelContext;
topicId?: string;
trigger?: string;
},
@@ -214,16 +234,17 @@ export class AgentBridgeService {
* by the bot-callback webhook endpoint.
*/
private async executeWithWebhooks(
thread: Thread<{ topicId?: string }>,
thread: Thread<ThreadState>,
userMessage: Message,
opts: {
agentId: string;
botContext?: ChatTopicBotContext;
channelContext?: DiscordChannelContext;
topicId?: string;
trigger?: string;
},
): Promise<{ reply: string; topicId: string }> {
const { agentId, botContext, topicId, trigger } = opts;
const { agentId, botContext, channelContext, topicId, trigger } = opts;
const aiAgentService = new AiAgentService(this.db, this.userId);
const timezone = await this.loadTimezone();
@@ -274,7 +295,10 @@ export class AgentBridgeService {
autoStart: true,
botContext,
completionWebhook: { body: webhookBody, url: callbackUrl },
prompt: userMessage.text,
discordContext: channelContext
? { channel: channelContext.channel, guild: channelContext.guild }
: undefined,
prompt: this.formatPrompt(userMessage, botContext),
stepWebhook: { body: webhookBody, url: callbackUrl },
trigger,
userInterventionConfig: { approvalMode: 'headless' },
@@ -295,16 +319,17 @@ export class AgentBridgeService {
* Local mode: use in-memory step callbacks and wait for completion via Promise.
*/
private async executeWithInMemoryCallbacks(
thread: Thread<{ topicId?: string }>,
thread: Thread<ThreadState>,
userMessage: Message,
opts: {
agentId: string;
botContext?: ChatTopicBotContext;
channelContext?: DiscordChannelContext;
topicId?: string;
trigger?: string;
},
): Promise<{ reply: string; topicId: string }> {
const { agentId, botContext, topicId, trigger } = opts;
const { agentId, botContext, channelContext, topicId, trigger } = opts;
const aiAgentService = new AiAgentService(this.db, this.userId);
const timezone = await this.loadTimezone();
@@ -341,7 +366,10 @@ export class AgentBridgeService {
appContext: topicId ? { topicId } : undefined,
autoStart: true,
botContext,
prompt: userMessage.text,
discordContext: channelContext
? { channel: channelContext.channel, guild: channelContext.guild }
: undefined,
prompt: this.formatPrompt(userMessage, botContext),
stepCallbacks: {
onAfterStep: async (stepData) => {
const { content, shouldContinue, toolsCalling } = stepData;
@@ -457,6 +485,79 @@ export class AgentBridgeService {
});
}
/**
* Fetch channel context from the Chat SDK adapter.
* Uses fetchThread to get channel name, and decodeThreadId to extract guild/channel IDs.
*/
private async fetchChannelContext(
thread: Thread<ThreadState>,
): Promise<DiscordChannelContext | undefined> {
try {
// Decode thread ID to get guild and channel IDs
// Discord format: "discord:guildId:channelId[:threadId]"
const decoded = thread.adapter.decodeThreadId(thread.id) as {
channelId?: string;
guildId?: string;
};
if (!decoded?.guildId || !decoded?.channelId) {
log('fetchChannelContext: could not decode guildId/channelId from thread %s', thread.id);
return undefined;
}
// Fetch thread info to get channel name and metadata
const threadInfo = await thread.adapter.fetchThread(thread.id);
const raw = threadInfo.metadata?.raw as { topic?: string; type?: number } | undefined;
const context: DiscordChannelContext = {
channel: {
id: decoded.channelId,
name: threadInfo.channelName,
topic: raw?.topic,
type: raw?.type,
},
guild: { id: decoded.guildId },
};
log(
'fetchChannelContext: guild=%s, channel=%s (%s)',
decoded.guildId,
decoded.channelId,
threadInfo.channelName,
);
return context;
} catch (error) {
log('fetchChannelContext: failed to fetch channel context: %O', error);
return undefined;
}
}
/**
* Format user message into agent prompt:
* 1. Strip bot's own @mention (Discord format: <@botId>)
* 2. Add speaker tag with user identity
*/
private formatPrompt(message: Message, botContext?: ChatTopicBotContext): string {
let text = message.text;
if (botContext?.applicationId) {
text = text.replaceAll(new RegExp(`<@!?${botContext.applicationId}>\\s*`, 'g'), '').trim();
}
const { userId, userName, fullName } = message.author;
const raw = (message as any).raw?.author as
| { avatar?: string | null; global_name?: string | null }
| undefined;
const avatar = raw?.avatar ?? '';
const globalName = raw?.global_name ?? fullName;
return formatSpeakerMessage(
{ avatar, id: userId, nickname: globalName, username: userName },
text,
);
}
/**
* Lazily load and cache user timezone from settings.
*/
@@ -481,7 +582,7 @@ export class AgentBridgeService {
* Remove the received reaction from a user message (fire-and-forget).
*/
private async removeReceivedReaction(
thread: Thread<{ topicId?: string }>,
thread: Thread<ThreadState>,
message: Message,
): Promise<void> {
await safeReaction(
@@ -144,12 +144,24 @@ describe('replyTemplate', () => {
thinking: false,
}),
),
).toBe(`Here is my response\n\n`);
).toBe(`Here is my response`);
});
it('should show processing fallback when no content at all', () => {
expect(renderLLMGenerating(makeParams({ thinking: false }))).toBe(`💭 Processing...`);
});
it('should trim leading/trailing newlines from content to prevent extra blank lines', () => {
expect(
renderLLMGenerating(
makeParams({
content: '\n\nHere is my response\n\n',
thinking: false,
toolsCalling: [{ apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' }],
}),
),
).toBe('Here is my response\n\n○ **builtin·search**(q: "test")');
});
});
// ==================== renderToolExecuting ====================
@@ -170,7 +182,7 @@ describe('replyTemplate', () => {
}),
),
).toBe(
`I will search for that.\n\n⏺ **builtin·web_search**(query: "test")\n ⎿ success:15 chars\n\n💭 Processing...`,
`I will search for that.\n\n⏺ **builtin·web_search**(query: "test")\n⎿ success: 15 chars\n\n💭 Processing...`,
);
});
@@ -210,7 +222,7 @@ describe('replyTemplate', () => {
}),
),
).toBe(
`⏺ **builtin·search**(q: "test")\n ⎿ success:15 chars\n⏺ **lobe-web-browsing·readUrl**(url: "https://example.com")\n ⎿ success:24 chars\n\n💭 Processing...`,
`⏺ **builtin·search**(q: "test")\n⎿ success: 15 chars\n⏺ **lobe-web-browsing·readUrl**(url: "https://example.com")\n⎿ success: 24 chars\n\n💭 Processing...`,
);
});
@@ -228,6 +240,23 @@ describe('replyTemplate', () => {
it('should show processing fallback when no lastContent and no tools', () => {
expect(renderToolExecuting(makeParams({ stepType: 'call_tool' }))).toBe(`💭 Processing...`);
});
it('should trim leading/trailing newlines from lastContent to prevent extra blank lines', () => {
expect(
renderToolExecuting(
makeParams({
lastContent: '\n\nI will search for that.\n\n',
lastToolsCalling: [
{ apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' },
],
stepType: 'call_tool',
toolsResult: [{ apiName: 'search', identifier: 'builtin', output: 'Found results' }],
}),
),
).toBe(
`I will search for that.\n\n⏺ **builtin·search**(q: "test")\n⎿ success: 13 chars\n\n💭 Processing...`,
);
});
});
// ==================== summarizeOutput ====================
@@ -240,7 +269,7 @@ describe('replyTemplate', () => {
});
it('should show char count for output', () => {
expect(summarizeOutput('Hello world')).toBe('success:11 chars');
expect(summarizeOutput('Hello world')).toBe('success: 11 chars');
});
it('should show char count for long output', () => {
@@ -249,7 +278,15 @@ describe('replyTemplate', () => {
});
it('should show char count for multi-line output', () => {
expect(summarizeOutput('line1\nline2\nline3')).toBe('success:17 chars');
expect(summarizeOutput('line1\nline2\nline3')).toBe('success: 17 chars');
});
it('should show error status when isSuccess is false', () => {
expect(summarizeOutput('Something went wrong', false)).toBe('error: 20 chars');
});
it('should show success status when isSuccess is true', () => {
expect(summarizeOutput('All good', true)).toBe('success: 8 chars');
});
});
@@ -379,7 +416,7 @@ describe('replyTemplate', () => {
}),
),
).toBe(
`Previous content\n\n⏺ **builtin·search**(q: "test")\n ⎿ success:13 chars\n\n💭 Processing...`,
`Previous content\n\n⏺ **builtin·search**(q: "test")\n⎿ success: 13 chars\n\n💭 Processing...`,
);
});
});
+15 -10
View File
@@ -40,7 +40,7 @@ export function splitMessage(text: string, limit = DEFAULT_CHAR_LIMIT): string[]
// ==================== Params ====================
type ToolCallItem = { apiName: string; arguments?: string; identifier: string };
type ToolResultItem = { apiName: string; identifier: string; output?: string };
type ToolResultItem = { apiName: string; identifier: string; isSuccess?: boolean; output?: string };
export interface RenderStepParams extends StepPresentationData {
elapsedMs?: number;
@@ -72,13 +72,17 @@ function formatToolCall(tc: ToolCallItem): string {
return formatToolName(tc);
}
export function summarizeOutput(output: string | undefined): string | undefined {
export function summarizeOutput(
output: string | undefined,
isSuccess?: boolean,
): string | undefined {
if (!output) return undefined;
const trimmed = output.trim();
if (trimmed.length === 0) return undefined;
const chars = trimmed.length;
return `success: ${chars.toLocaleString()} chars`;
const status = isSuccess === false ? 'error' : 'success';
return `${status}: ${chars.toLocaleString()} chars`;
}
function formatPendingTools(toolsCalling: ToolCallItem[]): string {
@@ -92,9 +96,10 @@ function formatCompletedTools(
return toolsCalling
.map((tc, i) => {
const callStr = `${formatToolCall(tc)}`;
const summary = summarizeOutput(toolsResult?.[i]?.output);
const result = toolsResult?.[i];
const summary = summarizeOutput(result?.output, result?.isSuccess);
if (summary) {
return `${callStr}\n ${summary}`;
return `${callStr}\n⎿ ${summary}`;
}
return callStr;
})
@@ -160,7 +165,7 @@ export function renderLLMGenerating(params: RenderStepParams): string {
totalTokens,
totalToolCalls,
} = params;
const displayContent = content || lastContent;
const displayContent = (content || lastContent)?.trim();
const { header, footer } = renderInlineStats({
elapsedMs,
totalCost,
@@ -178,12 +183,12 @@ export function renderLLMGenerating(params: RenderStepParams): string {
// Sub-state: has reasoning (thinking)
if (reasoning && !content) {
return `${header}${EMOJI_THINKING} ${reasoning}${footer}`;
return `${header}${EMOJI_THINKING} ${reasoning?.trim()}${footer}`;
}
// Sub-state: pure text content (waiting for next step)
if (displayContent) {
return `${header}${displayContent}\n\n${footer}`;
return `${header}${displayContent}${footer}`;
}
return `${header}${EMOJI_THINKING} Processing...${footer}`;
@@ -216,7 +221,7 @@ export function renderToolExecuting(params: RenderStepParams): string {
if (header) parts.push(header.trimEnd());
if (lastContent) parts.push(lastContent);
if (lastContent) parts.push(lastContent.trim());
if (lastToolsCalling && lastToolsCalling.length > 0) {
parts.push(formatCompletedTools(lastToolsCalling, toolsResult));
@@ -244,7 +249,7 @@ export function renderFinalReply(
const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : '';
const calls = llmCalls > 1 || toolCalls > 0 ? ` | llm×${llmCalls} | tools×${toolCalls}` : '';
const footer = `-# ${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}${time}${calls}`;
return `${content}\n\n${footer}`;
return `${content.trimEnd()}\n\n${footer}`;
}
export function renderError(errorMessage: string): string {
-6
View File
@@ -1,11 +1,5 @@
{
"buildCommand": "bun run build:vercel",
"crons": [
{
"path": "/api/agent/gateway/discord",
"schedule": "*/9 * * * *"
}
],
"installCommand": "npx pnpm@10.26.2 install",
"rewrites": [
{