refactor: less plugins

This commit is contained in:
lin onetwo 2025-08-01 01:24:48 +08:00
parent e47941f71a
commit d2a37dac87
26 changed files with 836 additions and 427 deletions

View file

@ -83,9 +83,9 @@ describe('URL Helper Functions', () => {
});
test('should handle non-string inputs', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(equivalentDomain(null as any)).toBeUndefined();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(equivalentDomain(123 as any)).toBeUndefined();
});
});

View file

@ -5,22 +5,27 @@ import SmartToyIcon from '@mui/icons-material/SmartToy';
import { Avatar, Box } from '@mui/material';
import { styled } from '@mui/material/styles';
import React from 'react';
import { isMessageExpiredForAI } from '../../../services/agentInstance/utilities/messageDurationFilter';
import { useAgentChatStore } from '../../Agent/store/agentChatStore/index';
import { MessageRenderer } from './MessageRenderer';
const BubbleContainer = styled(Box)<{ $isUser: boolean }>`
const BubbleContainer = styled(Box)<{ $isUser: boolean; $isExpired?: boolean }>`
display: flex;
gap: 12px;
max-width: 80%;
align-self: ${props => props.$isUser ? 'flex-end' : 'flex-start'};
opacity: ${props => props.$isExpired ? 0.5 : 1};
transition: opacity 0.3s ease-in-out;
`;
const MessageAvatar = styled(Avatar)<{ $isUser: boolean }>`
const MessageAvatar = styled(Avatar)<{ $isUser: boolean; $isExpired?: boolean }>`
background-color: ${props => props.$isUser ? props.theme.palette.primary.main : props.theme.palette.secondary.main};
color: ${props => props.$isUser ? props.theme.palette.primary.contrastText : props.theme.palette.secondary.contrastText};
opacity: ${props => props.$isExpired ? 0.7 : 1};
transition: opacity 0.3s ease-in-out;
`;
const MessageContent = styled(Box)<{ $isUser: boolean; $isStreaming?: boolean }>`
const MessageContent = styled(Box)<{ $isUser: boolean; $isStreaming?: boolean; $isExpired?: boolean }>`
background-color: ${props => props.$isUser ? props.theme.palette.primary.light : props.theme.palette.background.paper};
color: ${props => props.$isUser ? props.theme.palette.primary.contrastText : props.theme.palette.text.primary};
padding: 12px 16px;
@ -28,10 +33,18 @@ const MessageContent = styled(Box)<{ $isUser: boolean; $isStreaming?: boolean }>
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
position: relative;
transition: all 0.3s ease-in-out;
opacity: ${props => props.$isExpired ? 0.6 : 1};
/* Add visual indicators for expired messages */
${props =>
props.$isExpired && `
border: 1px dashed ${props.theme.palette.divider};
filter: grayscale(0.3);
`}
/* Add a subtle highlight for completed assistant messages */
${props =>
!props.$isUser && !props.$isStreaming && `
!props.$isUser && !props.$isStreaming && !props.$isExpired && `
border-left: 2px solid ${props.theme.palette.divider};
`}
@ -70,25 +83,31 @@ interface MessageBubbleProps {
export const MessageBubble: React.FC<MessageBubbleProps> = ({ messageId }) => {
const message = useAgentChatStore(state => state.getMessageById(messageId));
const isStreaming = useAgentChatStore(state => state.isMessageStreaming(messageId));
const orderedMessageIds = useAgentChatStore(state => state.orderedMessageIds);
if (!message) return null;
const isUser = message.role === 'user';
// Calculate if message is expired for AI context
const messageIndex = orderedMessageIds.indexOf(messageId);
const totalMessages = orderedMessageIds.length;
const isExpired = isMessageExpiredForAI(message, messageIndex, totalMessages);
return (
<BubbleContainer $isUser={isUser}>
<BubbleContainer $isUser={isUser} $isExpired={isExpired}>
{!isUser && (
<MessageAvatar $isUser={isUser}>
<MessageAvatar $isUser={isUser} $isExpired={isExpired}>
<SmartToyIcon />
</MessageAvatar>
)}
<MessageContent $isUser={isUser} $isStreaming={isStreaming}>
<MessageContent $isUser={isUser} $isStreaming={isStreaming} $isExpired={isExpired}>
<MessageRenderer message={message} isUser={isUser} />
</MessageContent>
{isUser && (
<MessageAvatar $isUser={isUser}>
<MessageAvatar $isUser={isUser} $isExpired={isExpired}>
<PersonIcon />
</MessageAvatar>
)}

View file

@ -13,9 +13,9 @@ import Tooltip from '@mui/material/Tooltip';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow';
import { PreviewProgressBar } from './PreviewProgressBar';
import { useAgentChatStore } from '../../../Agent/store/agentChatStore/index';
import { EditView } from './EditView';
import { PreviewProgressBar } from './PreviewProgressBar';
import { PreviewTabsView } from './PreviewTabsView';
interface PromptPreviewDialogProps {

View file

@ -335,7 +335,7 @@ export class AgentDefinitionService implements IAgentDefinitionService {
required: ['workspaceId', 'query'],
},
});
return Promise.resolve([wikiSearchTool]);
} catch (error) {
logger.error(`Failed to get available tools: ${error as Error}`);

View file

@ -3,6 +3,7 @@
* Tests the complete workflow: tool list injection -> AI response -> tool execution -> next round
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AgentInstanceMessage } from '../../interface';
import { WikiChannel } from '@/constants/channels';
import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility';
@ -104,6 +105,11 @@ describe('WikiSearch Plugin Integration', () => {
// Phase 1: Tool List Injection
const promptContext = {
handlerContext: {
agent: { id: 'test', messages: [], agentDefId: 'test', status: { state: 'working' as const, modified: new Date() }, created: new Date() },
agentDef: { id: 'test', name: 'test' },
isCancelled: () => false,
},
pluginConfig: wikiPlugin as any, // Cast to avoid type complexity in tests
prompts,
messages: [
@ -207,10 +213,14 @@ describe('WikiSearch Plugin Integration', () => {
// Verify tool results were set up for next round
expect(responseContext.actions.yieldNextRoundTo).toBe('self');
expect(responseContext.actions.newUserMessage).toContain('<functions_result>');
expect(responseContext.actions.newUserMessage).toContain('Tool: wiki-search');
expect(responseContext.actions.newUserMessage).toContain('Important Note 1');
expect(responseContext.actions.newUserMessage).toContain('Important Note 2');
// Verify tool result message was added to agent history
expect(responseContext.handlerContext.agent.messages.length).toBeGreaterThan(0);
const toolResultMessage = responseContext.handlerContext.agent.messages[responseContext.handlerContext.agent.messages.length - 1] as AgentInstanceMessage;
expect(toolResultMessage.role).toBe('user');
expect(toolResultMessage.content).toContain('<functions_result>');
expect(toolResultMessage.content).toContain('Tool: wiki-search');
expect(toolResultMessage.content).toContain('Important Note 1');
});
it('should handle errors in wiki search gracefully', async () => {
@ -274,8 +284,13 @@ describe('WikiSearch Plugin Integration', () => {
// Should still set up next round even with error
expect(responseContext.actions.yieldNextRoundTo).toBe('self');
expect(responseContext.actions.newUserMessage).toContain('Error:');
expect(responseContext.actions.newUserMessage).toContain('does not exist');
// Verify error message was added to agent history
expect(responseContext.handlerContext.agent.messages.length).toBeGreaterThan(0);
const errorResultMessage = responseContext.handlerContext.agent.messages[responseContext.handlerContext.agent.messages.length - 1] as AgentInstanceMessage;
expect(errorResultMessage.role).toBe('user');
expect(errorResultMessage.content).toContain('Error:');
expect(errorResultMessage.content).toContain('does not exist');
});
});
});

View file

@ -4,7 +4,8 @@ import { logger } from '@services/libs/log';
import serviceIdentifier from '@services/serviceIdentifier';
import { merge } from 'lodash';
import { AgentInstanceLatestStatus, AgentInstanceMessage, IAgentInstanceService } from '../interface';
import { createHandlerHooks, registerBuiltInHandlerPlugins } from '../plugins';
import { createHandlerHooks, registerAllBuiltInPlugins } from '../plugins';
import { YieldNextRoundTarget } from '../plugins/types';
import { AgentPromptDescription, AiAPIConfig, HandlerConfig } from '../promptConcat/promptConcatSchema';
import { responseConcat } from '../promptConcat/responseConcat';
import { getFinalPromptResult } from '../promptConcat/utils';
@ -34,7 +35,7 @@ export async function* basicPromptConcatHandler(context: AgentHandlerContext) {
// Create and register handler hooks
const handlerHooks = createHandlerHooks();
registerBuiltInHandlerPlugins(handlerHooks);
registerAllBuiltInPlugins(handlerHooks);
// Log the start of handler execution with context information
logger.debug('Starting prompt handler execution', {
@ -108,7 +109,7 @@ export async function* basicPromptConcatHandler(context: AgentHandlerContext) {
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
// Generate AI response
// Function to process a single LLM call with retry support
async function* processLLMCall(_userMessage: string): AsyncGenerator<AgentInstanceLatestStatus> {
async function* processLLMCall(): AsyncGenerator<AgentInstanceLatestStatus> {
try {
// Delegate prompt concatenation to plugin system
// Re-generate prompts to trigger middleware (including retrievalAugmentedGenerationHandler)
@ -163,24 +164,40 @@ export async function* basicPromptConcatHandler(context: AgentHandlerContext) {
});
// Delegate final response processing to handler hooks
await handlerHooks.responseComplete.promise({
const responseCompleteContext = {
handlerContext: context,
response,
requestId: currentRequestId,
isFinal: true,
});
actions: undefined as { yieldNextRoundTo?: 'self' | 'human'; newUserMessage?: string } | undefined,
};
await handlerHooks.responseComplete.promise(responseCompleteContext);
// Check if responseComplete hooks set yieldNextRoundTo
let yieldNextRoundFromHooks: YieldNextRoundTarget | undefined;
if (responseCompleteContext.actions?.yieldNextRoundTo) {
yieldNextRoundFromHooks = responseCompleteContext.actions.yieldNextRoundTo;
logger.debug('Response complete hooks triggered yield next round', {
method: 'processLLMCall',
retryCount,
yieldNextRoundTo: yieldNextRoundFromHooks,
});
}
// Delegate response processing to plugin system
// Plugins can set yieldNextRoundTo actions to control conversation flow
const processedResult = await responseConcat(agentPromptDescription, response.content, context, context.agent.messages);
// Handle control flow based on plugin decisions
if (processedResult.needsNewLLMCall) {
// Handle control flow based on plugin decisions or responseComplete hooks
const shouldContinue = processedResult.yieldNextRoundTo === 'self' || yieldNextRoundFromHooks === 'self';
if (shouldContinue) {
// Control transfer: Continue with AI (yieldNextRoundTo: 'self')
logger.debug('Response processing triggered new LLM call', {
method: 'processLLMCall',
hasNewUserMessage: !!processedResult.newUserMessage,
retryCount,
fromResponseConcat: processedResult.yieldNextRoundTo,
fromResponseCompleteHooks: yieldNextRoundFromHooks,
});
// Prevent infinite loops with retry limit
@ -200,18 +217,15 @@ export async function* basicPromptConcatHandler(context: AgentHandlerContext) {
// Yield current response as working state
yield working(processedResult.processedResponse, context, currentRequestId);
// Continue with new round
// The necessary messages should already be added by plugins
logger.debug('Continuing with next round', {
method: 'basicPromptConcatHandler',
agentId: context.agent.id,
messageCount: context.agent.messages.length,
});
// Continue with new round - use provided message or last user message
const nextUserMessage = processedResult.newUserMessage || lastUserMessage?.content;
if (nextUserMessage) {
yield* processLLMCall(nextUserMessage);
} else {
logger.warn('No message provided for continue round', {
method: 'basicPromptConcatHandler',
agentId: context.agent.id,
});
yield completed(processedResult.processedResponse, context, currentRequestId);
}
yield* processLLMCall();
return;
}
@ -254,7 +268,7 @@ export async function* basicPromptConcatHandler(context: AgentHandlerContext) {
}
// Start processing with the initial user message
yield* processLLMCall(lastUserMessage.content);
yield* processLLMCall();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Error processing prompt', {

View file

@ -122,15 +122,7 @@
"pluginId": "autoReply",
"autoReplyParam": {
"targetId": "default-response",
"text": "继续工作直到你自己觉得工作已经完全完成。",
"trigger": {
"model": {
"preset": "defaultLite",
"system": "你是一个对话分析师,你将对目前的对话进行分析,确定AI的回复是否完全解决了用户的问题。一般来说,如果AI的回复只解决了用户提出的问题的一个子问题那么就需要继续工作。请注意,你需要结合上下文进行分析,而不是仅仅依赖于AI的回复内容。请不要做出1或者0之外的回答,回答中也不要包括其他文本内容。",
"user": "用户的消息内容为:<<input>>。如果此用户消息需要进行网络搜索,请回答1,如果不需要,请回答0。请不要做出1或者0之外的回答,回答中也不要包括其他文本内容。"
}
},
"maxAutoReply": 5
"text": "继续工作直到你自己觉得工作已经完全完成。如果根据之前的对话你认为任务已完成,则总结并结束对话。如果任务还未完成,你可以继续调用工具。"
}
}
]

View file

@ -8,7 +8,7 @@ import { DataSource, Repository } from 'typeorm';
import { IAgentDefinitionService } from '@services/agentDefinition/interface';
import { basicPromptConcatHandler } from '@services/agentInstance/buildInAgentHandlers/basicPromptConcatHandler';
import { AgentHandler, AgentHandlerContext } from '@services/agentInstance/buildInAgentHandlers/type';
import { createHandlerHooks, initializePluginSystem, registerBuiltInHandlerPlugins } from '@services/agentInstance/plugins';
import { createHandlerHooks, initializePluginSystem, registerAllBuiltInPlugins } from '@services/agentInstance/plugins';
import { promptConcatStream, PromptConcatStreamState } from '@services/agentInstance/promptConcat/promptConcat';
import { AgentPromptDescription } from '@services/agentInstance/promptConcat/promptConcatSchema';
import { promptConcatHandlerConfigJsonSchema } from '@services/agentInstance/promptConcat/promptConcatSchema/jsonSchema';
@ -21,7 +21,7 @@ import serviceIdentifier from '@services/serviceIdentifier';
import { IWikiService } from '@services/wiki/interface';
import { AgentInstance, AgentInstanceLatestStatus, AgentInstanceMessage, IAgentInstanceService } from './interface';
import { AGENT_INSTANCE_FIELDS, createAgentInstanceData, createAgentMessage, MESSAGE_FIELDS } from './utilities';
import { AGENT_INSTANCE_FIELDS, createAgentInstanceData, createAgentMessage, MESSAGE_FIELDS, toDatabaseCompatibleInstance, toDatabaseCompatibleMessage } from './utilities';
@injectable()
export class AgentInstanceService implements IAgentInstanceService {
@ -78,7 +78,7 @@ export class AgentInstanceService implements IAgentInstanceService {
initializePluginSystem();
// Register built-in handler plugins
registerBuiltInHandlerPlugins(this.handlerHooks);
registerAllBuiltInPlugins(this.handlerHooks);
// Register basic prompt concatenation handler with its schema
this.registerHandler('basicPromptConcatHandler', basicPromptConcatHandler, promptConcatHandlerConfigJsonSchema);
@ -139,7 +139,7 @@ export class AgentInstanceService implements IAgentInstanceService {
const { instanceData, instanceId, now } = createAgentInstanceData(agentDef);
// Create and save entity
const instanceEntity = this.agentInstanceRepository!.create(instanceData);
const instanceEntity = this.agentInstanceRepository!.create(toDatabaseCompatibleInstance(instanceData));
await this.agentInstanceRepository!.save(instanceEntity);
logger.info(`Created agent instance: ${instanceId}`);
@ -232,9 +232,8 @@ export class AgentInstanceService implements IAgentInstanceService {
await this.agentMessageRepository!.save(existingMessage);
} else {
// Create new message
const messageEntity = this.agentMessageRepository!.create(
pick(message, MESSAGE_FIELDS) as AgentInstanceMessage,
);
const messageData = pick(message, MESSAGE_FIELDS) as AgentInstanceMessage;
const messageEntity = this.agentMessageRepository!.create(toDatabaseCompatibleMessage(messageData));
await this.agentMessageRepository!.save(messageEntity);
@ -631,7 +630,7 @@ export class AgentInstanceService implements IAgentInstanceService {
public async saveUserMessage(userMessage: AgentInstanceMessage): Promise<void> {
this.ensureRepositories();
try {
await this.agentMessageRepository!.save(this.agentMessageRepository!.create(userMessage));
await this.agentMessageRepository!.save(this.agentMessageRepository!.create(toDatabaseCompatibleMessage(userMessage)));
logger.debug('User message saved to database', {
messageId: userMessage.id,
agentId: userMessage.agentId,
@ -691,14 +690,13 @@ export class AgentInstanceService implements IAgentInstanceService {
} else if (aid) {
// Create new message if it doesn't exist and agentId provided
// Create message using utility function
const newMessage = messageRepo.create(
createAgentMessage(messageId, aid, {
role: msgData.role,
content: msgData.content,
contentType: msgData.contentType,
metadata: msgData.metadata,
}),
);
const messageData = createAgentMessage(messageId, aid, {
role: msgData.role,
content: msgData.content,
contentType: msgData.contentType,
metadata: msgData.metadata,
});
const newMessage = messageRepo.create(toDatabaseCompatibleMessage(messageData));
await messageRepo.save(newMessage);
@ -770,7 +768,20 @@ export class AgentInstanceService implements IAgentInstanceService {
return new Observable<PromptConcatStreamState>((observer) => {
const processStream = async () => {
try {
const streamGenerator = promptConcatStream(promptDescription as AgentPromptDescription, messages);
// Create a minimal handler context for prompt concatenation
const handlerContext = {
agent: {
id: 'temp',
messages,
agentDefId: 'temp',
status: { state: 'working' as const, modified: new Date() },
created: new Date(),
},
agentDef: { id: 'temp', name: 'temp' },
isCancelled: () => false,
};
const streamGenerator = promptConcatStream(promptDescription as AgentPromptDescription, messages, handlerContext);
for await (const state of streamGenerator) {
observer.next(state);
if (state.isComplete) {

View file

@ -90,6 +90,13 @@ export interface AgentInstanceMessage {
metadata?: Record<string, unknown>;
/** Whether this message should be hidden from UI/history (default: false) */
hidden?: boolean;
/**
* Duration in rounds that this message should be included in AI context
* When set to a number > 0, the message will only be sent to AI for that many rounds from current position
* undefined/null means the message persists in AI context indefinitely (default behavior)
* 0 means the message is excluded from AI context immediately but remains visible in UI
*/
duration?: number | null;
}
/**

View file

@ -2,7 +2,7 @@
* Tests for the enhanced plugin system after refactoring
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createHandlerHooks, registerBuiltInHandlerPlugins } from '../index';
import { createHandlerHooks, registerAllBuiltInPlugins } from '../index';
describe('Plugin System', () => {
beforeEach(() => {
@ -22,12 +22,12 @@ describe('Plugin System', () => {
});
});
describe('registerBuiltInHandlerPlugins', () => {
describe('registerAllBuiltInPlugins', () => {
it('should register plugins without throwing', () => {
const hooks = createHandlerHooks();
expect(() => {
registerBuiltInHandlerPlugins(hooks);
registerAllBuiltInPlugins(hooks);
}).not.toThrow();
});
});

View file

@ -2,6 +2,7 @@
* Tests for Wiki Search plugin
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AgentInstanceMessage } from '../../interface';
import { WikiChannel } from '@/constants/channels';
import serviceIdentifier from '@services/serviceIdentifier';
@ -111,6 +112,11 @@ describe('Wiki Search Plugin', () => {
];
const context: PromptConcatHookContext = {
handlerContext: {
agent: { id: 'test', messages: [], agentDefId: 'test', status: { state: 'working' as const, modified: new Date() }, created: new Date() },
agentDef: { id: 'test', name: 'test' },
isCancelled: () => false,
},
pluginConfig: wikiPlugin,
prompts: prompts,
messages,
@ -269,11 +275,15 @@ describe('Wiki Search Plugin', () => {
// Verify that the search was executed and results were set up for next round
expect(context.actions.yieldNextRoundTo).toBe('self');
expect(context.actions.newUserMessage).toContain('<functions_result>');
expect(context.actions.newUserMessage).toContain('Tool: wiki-search');
expect(context.actions.newUserMessage).toContain('Important Note 1');
expect(context.actions.newUserMessage).toContain('Important Note 2');
expect(context.actions.newUserMessage).toContain('Content of Important Note 1');
// Verify tool result message was added to agent history
expect(handlerContext.agent.messages.length).toBeGreaterThan(0);
const toolResultMessage = handlerContext.agent.messages[handlerContext.agent.messages.length - 1] as AgentInstanceMessage;
expect(toolResultMessage.role).toBe('user');
expect(toolResultMessage.content).toContain('<functions_result>');
expect(toolResultMessage.content).toContain('Tool: wiki-search');
expect(toolResultMessage.content).toContain('Important Note 1');
expect(toolResultMessage.metadata?.isToolResult).toBe(true);
});
it('should handle wiki search errors gracefully', async () => {
@ -326,9 +336,16 @@ describe('Wiki Search Plugin', () => {
// Should still set up next round with error message
expect(context.actions.yieldNextRoundTo).toBe('self');
expect(context.actions.newUserMessage).toContain('<functions_result>');
expect(context.actions.newUserMessage).toContain('Error:');
expect(context.actions.newUserMessage).toContain('does not exist');
// Verify error message was added to agent history
expect(handlerContext.agent.messages.length).toBeGreaterThan(0);
const errorResultMessage = handlerContext.agent.messages[handlerContext.agent.messages.length - 1] as AgentInstanceMessage;
expect(errorResultMessage.role).toBe('user');
expect(errorResultMessage.content).toContain('<functions_result>');
expect(errorResultMessage.content).toContain('Error:');
expect(errorResultMessage.content).toContain('does not exist');
expect(errorResultMessage.metadata?.isToolResult).toBe(true);
expect(errorResultMessage.metadata?.isError).toBe(true);
});
it('should skip execution when no tool call is detected', async () => {

View file

@ -1,141 +0,0 @@
/**
* AI response history plugin
* Handles AI response streaming updates and completion
*/
import { container } from '@services/container';
import { logger } from '@services/libs/log';
import serviceIdentifier from '@services/serviceIdentifier';
import { AgentInstanceMessage, IAgentInstanceService } from '../interface';
import { AIResponseContext, PromptConcatPlugin } from './types';
/**
* AI response history plugin
* Manages AI response messages in conversation history during streaming and completion
*/
export const aiResponseHistoryPlugin: PromptConcatPlugin = (hooks) => {
// Handle AI response updates (streaming)
hooks.responseUpdate.tapAsync('aiResponseHistoryPlugin', (context: AIResponseContext, callback) => {
try {
const { handlerContext, response } = context;
if (response.status === 'update' && response.content) {
// Find or create AI response message
let aiMessage = handlerContext.agent.messages.find(
(message) => message.role === 'assistant' && !message.metadata?.isComplete,
);
if (!aiMessage) {
// Create new AI message for streaming updates
aiMessage = {
id: `ai-response-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
agentId: handlerContext.agent.id,
role: 'assistant',
content: response.content,
modified: new Date(),
metadata: { isComplete: false },
};
handlerContext.agent.messages.push(aiMessage);
} else {
// Update existing message content
aiMessage.content = response.content;
aiMessage.modified = new Date();
}
// Update UI using the agent instance service
try {
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
agentInstanceService.debounceUpdateMessage(aiMessage, handlerContext.agent.id);
} catch (serviceError) {
logger.warn('Failed to update UI for message', {
error: serviceError instanceof Error ? serviceError.message : String(serviceError),
messageId: aiMessage.id,
});
}
logger.debug('AI response message updated', {
messageId: aiMessage.id,
contentLength: response.content.length,
});
}
callback();
} catch (error) {
logger.error('AI response history plugin error in responseUpdate', {
error: error instanceof Error ? error.message : String(error),
});
callback();
}
});
// Handle AI response completion
hooks.responseComplete.tapAsync('aiResponseHistoryPlugin', (context: AIResponseContext, callback) => {
try {
const { handlerContext, response } = context;
if (response.status === 'done' && response.content) {
// Find and finalize AI response message
const aiMessage = handlerContext.agent.messages.find(
(message) => message.role === 'assistant' && !message.metadata?.isComplete,
);
if (aiMessage) {
// Mark as complete and update final content
aiMessage.content = response.content;
aiMessage.modified = new Date();
aiMessage.metadata = { ...aiMessage.metadata, isComplete: true };
// Final UI update
try {
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
agentInstanceService.debounceUpdateMessage(aiMessage, handlerContext.agent.id);
} catch (serviceError) {
logger.warn('Failed to update UI for completed message', {
error: serviceError instanceof Error ? serviceError.message : String(serviceError),
messageId: aiMessage.id,
});
}
logger.debug('AI response message completed', {
messageId: aiMessage.id,
finalContentLength: response.content.length,
});
} else {
// Create final message if streaming message wasn't found
const finalMessage: AgentInstanceMessage = {
id: `ai-response-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
agentId: handlerContext.agent.id,
role: 'assistant',
content: response.content,
modified: new Date(),
metadata: { isComplete: true },
};
handlerContext.agent.messages.push(finalMessage);
// UI update for final message
try {
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
agentInstanceService.debounceUpdateMessage(finalMessage, handlerContext.agent.id);
} catch (serviceError) {
logger.warn('Failed to update UI for final message', {
error: serviceError instanceof Error ? serviceError.message : String(serviceError),
messageId: finalMessage.id,
});
}
logger.debug('AI response message created as final', {
messageId: finalMessage.id,
contentLength: response.content.length,
});
}
}
callback();
} catch (error) {
logger.error('AI response history plugin error in responseComplete', {
error: error instanceof Error ? error.message : String(error),
});
callback();
}
});
};

View file

@ -1,19 +1,9 @@
import { logger } from '@services/libs/log';
import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable';
import {
AgentResponse,
PromptConcatHooks,
PromptConcatHookContext,
PromptConcatPlugin,
ResponseHookContext,
UserMessageContext,
AgentStatusContext,
ToolExecutionContext,
AIResponseContext
} from './types';
import { AgentResponse, PromptConcatHookContext, PromptConcatHooks, PromptConcatPlugin, ResponseHookContext } from './types';
// Re-export types for convenience
export type { AgentResponse, PromptConcatHooks, PromptConcatHookContext, PromptConcatPlugin, ResponseHookContext };
export type { AgentResponse, PromptConcatHookContext, PromptConcatHooks, PromptConcatPlugin, ResponseHookContext };
/**
* Registry for built-in plugins
@ -28,40 +18,6 @@ export function registerBuiltInPlugin(pluginId: string, plugin: PromptConcatPlug
logger.debug(`Registered built-in plugin: ${pluginId}`);
}
/**
* Register all built-in plugins
*/
export function registerAllBuiltInPlugins(): void {
// Use dynamic imports to avoid circular dependency issues
Promise.all([
import('./promptPlugins'),
import('./responsePlugins'),
import('./wikiSearchPlugin'),
]).then(([promptPluginsModule, responsePluginsModule, wikiSearchModule]) => {
// Prompt processing plugins
registerBuiltInPlugin('fullReplacement', promptPluginsModule.fullReplacementPlugin);
registerBuiltInPlugin('dynamicPosition', promptPluginsModule.dynamicPositionPlugin);
registerBuiltInPlugin('modelContextProtocol', promptPluginsModule.modelContextProtocolPlugin);
// Wiki search plugin - handles both prompt and response processing
registerBuiltInPlugin('wikiSearch', wikiSearchModule.wikiSearchPlugin);
// Response processing plugins
registerBuiltInPlugin('autoReply', responsePluginsModule.autoReplyPlugin);
logger.debug('All built-in plugins registered successfully');
}).catch((error: unknown) => {
logger.error('Failed to register built-in plugins:', error);
});
}
/**
* Initialize plugin system
*/
export function initializePluginSystem(): void {
registerAllBuiltInPlugins();
}
/**
* Create unified hooks instance for the complete plugin system
*/
@ -81,24 +37,67 @@ export function createHandlerHooks(): PromptConcatHooks {
}
/**
* Register built-in handler plugins
* Register all built-in plugins to hooks
*/
export function registerBuiltInHandlerPlugins(hooks: PromptConcatHooks): void {
// Import and register persistence plugin first (handles database operations)
import('./persistencePlugin').then(module => {
module.persistencePlugin(hooks);
logger.debug('Registered persistencePlugin');
}).catch((error: unknown) => {
logger.error('Failed to register persistencePlugin:', error);
});
export function registerAllBuiltInPlugins(hooks?: PromptConcatHooks): void {
// If hooks provided, register plugins directly to hooks for immediate use
if (hooks) {
// Import and register message management plugin first (handles database operations, message persistence, and UI updates)
import('./messageManagementPlugin').then(module => {
module.messageManagementPlugin(hooks);
logger.debug('Registered messageManagementPlugin to hooks');
}).catch((error: unknown) => {
logger.error('Failed to register messageManagementPlugin to hooks:', error);
});
// Import and register wiki search handler plugin
import('./wikiSearchPlugin').then(module => {
module.wikiSearchPlugin(hooks);
logger.debug('Registered wikiSearchPlugin');
}).catch((error: unknown) => {
logger.error('Failed to register wikiSearchPlugin:', error);
});
// Import and register wiki search handler plugin
import('./wikiSearchPlugin').then(module => {
module.wikiSearchPlugin(hooks);
logger.debug('Registered wikiSearchPlugin to hooks');
}).catch((error: unknown) => {
logger.error('Failed to register wikiSearchPlugin to hooks:', error);
});
logger.debug('Built-in handler plugins registration initiated');
// Temporarily disable auto reply plugin to debug
// import('./responsePlugins').then(module => {
// module.autoReplyPlugin(hooks);
// logger.debug('Registered autoReplyPlugin to hooks');
// }).catch((error: unknown) => {
// logger.error('Failed to register autoReplyPlugin to hooks:', error);
// });
logger.debug('Built-in plugins registration to hooks initiated');
return;
}
// Otherwise, register plugins to global registry for plugin discovery
Promise.all([
import('./promptPlugins'),
import('./responsePlugins'),
import('./wikiSearchPlugin'),
import('./messageManagementPlugin'),
]).then(([promptPluginsModule, _responsePluginsModule, wikiSearchModule, messageManagementModule]) => {
// Message management plugin (should be first to handle message persistence and UI updates)
registerBuiltInPlugin('messageManagement', messageManagementModule.messageManagementPlugin);
// Prompt processing plugins
registerBuiltInPlugin('fullReplacement', promptPluginsModule.fullReplacementPlugin);
// Wiki search plugin - handles both prompt and response processing
registerBuiltInPlugin('wikiSearch', wikiSearchModule.wikiSearchPlugin);
// Temporarily disable auto reply plugin
// registerBuiltInPlugin('autoReply', responsePluginsModule.autoReplyPlugin);
logger.debug('All built-in plugins registered to global registry successfully');
}).catch((error: unknown) => {
logger.error('Failed to register built-in plugins to global registry:', error);
});
}
/**
* Initialize plugin system
*/
export function initializePluginSystem(): void {
registerAllBuiltInPlugins();
}

View file

@ -1,21 +1,22 @@
/**
* Persistence plugin for database operations and UI updates
* Handles user message storage, agent status updates, AI response management and UI synchronization
* Message management plugin
* Unified plugin for handling message persistence, streaming updates, and UI synchronization
* Combines functionality from persistencePlugin and aiResponseHistoryPlugin
*/
import { container } from '@services/container';
import { logger } from '@services/libs/log';
import serviceIdentifier from '@services/serviceIdentifier';
import { IAgentInstanceService } from '../interface';
import type { IAgentInstanceService } from '../interface';
import { createAgentMessage } from '../utilities';
import { AgentStatusContext, AIResponseContext, PromptConcatPlugin, UserMessageContext } from './types';
import type { AgentStatusContext, AIResponseContext, PromptConcatPlugin, ToolExecutionContext, UserMessageContext } from './types';
/**
* Persistence plugin
* Manages database operations, message history and UI updates for all agent interactions
* Message management plugin
* Handles all message-related operations: persistence, streaming, UI updates, and duration-based filtering
*/
export const persistencePlugin: PromptConcatPlugin = (hooks) => {
export const messageManagementPlugin: PromptConcatPlugin = (hooks) => {
// Handle user message persistence
hooks.userMessageReceived.tapAsync('persistencePlugin', async (context: UserMessageContext, callback) => {
hooks.userMessageReceived.tapAsync('messageManagementPlugin', async (context: UserMessageContext, callback) => {
try {
const { handlerContext, content, messageId } = context;
@ -25,6 +26,7 @@ export const persistencePlugin: PromptConcatPlugin = (hooks) => {
content: content.text,
contentType: 'text/plain',
metadata: content.file ? { file: content.file } : undefined,
duration: undefined, // User messages persist indefinitely by default
});
// Get the agent instance service to access repositories
@ -44,7 +46,7 @@ export const persistencePlugin: PromptConcatPlugin = (hooks) => {
callback();
} catch (error) {
logger.error('Persistence plugin error in userMessageReceived', {
logger.error('Message management plugin error in userMessageReceived', {
error: error instanceof Error ? error.message : String(error),
messageId: context.messageId,
agentId: context.handlerContext.agent.id,
@ -54,7 +56,7 @@ export const persistencePlugin: PromptConcatPlugin = (hooks) => {
});
// Handle agent status persistence
hooks.agentStatusChanged.tapAsync('persistencePlugin', async (context: AgentStatusContext, callback) => {
hooks.agentStatusChanged.tapAsync('messageManagementPlugin', async (context: AgentStatusContext, callback) => {
try {
const { handlerContext, status } = context;
@ -76,7 +78,7 @@ export const persistencePlugin: PromptConcatPlugin = (hooks) => {
callback();
} catch (error) {
logger.error('Persistence plugin error in agentStatusChanged', {
logger.error('Message management plugin error in agentStatusChanged', {
error: error instanceof Error ? error.message : String(error),
agentId: context.handlerContext.agent.id,
status: context.status,
@ -86,7 +88,7 @@ export const persistencePlugin: PromptConcatPlugin = (hooks) => {
});
// Handle AI response updates during streaming
hooks.responseUpdate.tapAsync('persistencePlugin', (context: AIResponseContext, callback) => {
hooks.responseUpdate.tapAsync('messageManagementPlugin', (context: AIResponseContext, callback) => {
try {
const { handlerContext, response } = context;
@ -105,6 +107,7 @@ export const persistencePlugin: PromptConcatPlugin = (hooks) => {
content: response.content,
modified: new Date(),
metadata: { isComplete: false },
duration: undefined, // AI responses persist indefinitely by default
};
handlerContext.agent.messages.push(aiMessage);
} else {
@ -123,16 +126,11 @@ export const persistencePlugin: PromptConcatPlugin = (hooks) => {
messageId: aiMessage.id,
});
}
logger.debug('AI response message updated during streaming', {
messageId: aiMessage.id,
contentLength: response.content.length,
});
}
callback();
} catch (error) {
logger.error('Persistence plugin error in responseUpdate', {
logger.error('Message management plugin error in responseUpdate', {
error: error instanceof Error ? error.message : String(error),
});
callback();
@ -140,7 +138,7 @@ export const persistencePlugin: PromptConcatPlugin = (hooks) => {
});
// Handle AI response completion
hooks.responseComplete.tapAsync('persistencePlugin', async (context: AIResponseContext, callback) => {
hooks.responseComplete.tapAsync('messageManagementPlugin', async (context: AIResponseContext, callback) => {
try {
const { handlerContext, response } = context;
@ -163,7 +161,10 @@ export const persistencePlugin: PromptConcatPlugin = (hooks) => {
role: 'assistant',
content: response.content,
modified: new Date(),
metadata: { isComplete: true },
metadata: {
isComplete: true,
},
duration: undefined, // Default duration for AI responses
};
handlerContext.agent.messages.push(aiMessage);
}
@ -192,7 +193,47 @@ export const persistencePlugin: PromptConcatPlugin = (hooks) => {
callback();
} catch (error) {
logger.error('Persistence plugin error in responseComplete', {
logger.error('Message management plugin error in responseComplete', {
error: error instanceof Error ? error.message : String(error),
});
callback();
}
});
// Handle tool result messages persistence and UI updates
hooks.toolExecuted.tapAsync('messageManagementPlugin', (context: ToolExecutionContext, callback) => {
try {
const { handlerContext } = context;
// Update UI for any newly added messages with duration settings
const newMessages = handlerContext.agent.messages.filter(
(message) => message.metadata?.isToolResult && !message.metadata.uiUpdated,
);
for (const message of newMessages) {
try {
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
agentInstanceService.debounceUpdateMessage(message, handlerContext.agent.id);
// Mark as UI updated to avoid duplicate updates
message.metadata = { ...message.metadata, uiUpdated: true };
} catch (serviceError) {
logger.warn('Failed to update UI for tool result message', {
error: serviceError instanceof Error ? serviceError.message : String(serviceError),
messageId: message.id,
});
}
}
if (newMessages.length > 0) {
logger.debug('Tool result messages UI updated', {
count: newMessages.length,
messageIds: newMessages.map(m => m.id),
});
}
callback();
} catch (error) {
logger.error('Message management plugin error in toolExecuted', {
error: error instanceof Error ? error.message : String(error),
});
callback();

View file

@ -6,7 +6,8 @@ import { logger } from '@services/libs/log';
import { cloneDeep } from 'lodash';
import { findPromptById } from '../promptConcat/promptConcat';
import { IPrompt } from '../promptConcat/promptConcatSchema';
import { AgentResponse, ResponseHookContext, PromptConcatPlugin } from './types';
import { filterMessagesByDuration } from '../utilities/messageDurationFilter';
import { AgentResponse, PromptConcatPlugin, ResponseHookContext } from './types';
/**
* Full replacement plugin
@ -37,14 +38,16 @@ export const fullReplacementPlugin: PromptConcatPlugin = (hooks) => {
// Get all messages except the last one which is the user message
const messagesCopy = cloneDeep(messages);
messagesCopy.pop(); // Last message is the user message
const history = messagesCopy; // Remaining messages are history
// Apply duration filtering to exclude expired messages from AI context
const filteredHistory = filterMessagesByDuration(messagesCopy);
switch (sourceType) {
case 'historyOfSession':
if (history.length > 0) {
// Insert history messages as Prompt children (full Prompt type)
if (filteredHistory.length > 0) {
// Insert filtered history messages as Prompt children (full Prompt type)
found.prompt.children = [];
history.forEach((message, index: number) => {
filteredHistory.forEach((message, index: number) => {
// Use the role type from Prompt
type PromptRole = NonNullable<IPrompt['role']>;
const role: PromptRole = message.role === 'agent'

View file

@ -1,15 +1,23 @@
/**
* Response processing plugins
*/
import { container } from '@services/container';
import { logger } from '@services/libs/log';
import { ResponseHookContext, PromptConcatPlugin } from './types';
import serviceIdentifier from '@services/serviceIdentifier';
import type { IAgentInstanceService } from '../interface';
import type { AgentInstanceMessage } from '../interface';
import { PromptConcatPlugin, ResponseHookContext } from './types';
/**
* Auto reply plugin
* Automatically generates follow-up responses
*/
export const autoReplyPlugin: PromptConcatPlugin = (hooks) => {
hooks.postProcess.tapAsync('autoReplyPlugin', (context, callback) => {
// Map to store auto reply configs by agent ID
const autoReplyConfigs = new Map<string, { text: string; targetId: string }>();
// First pass: Mark for auto reply in postProcess
hooks.postProcess.tapAsync('autoReplyPlugin-mark', (context, callback) => {
const { pluginConfig } = context as ResponseHookContext;
if (pluginConfig.pluginId !== 'autoReply' || !pluginConfig.autoReplyParam) {
@ -17,28 +25,107 @@ export const autoReplyPlugin: PromptConcatPlugin = (hooks) => {
return;
}
const { targetId, text, maxAutoReply = 5 } = pluginConfig.autoReplyParam;
const { targetId, text } = pluginConfig.autoReplyParam;
try {
// Auto reply is always triggered since we removed trigger conditions
logger.info('Auto reply plugin triggered', {
logger.debug('Auto reply plugin triggered', {
targetId,
text: text.substring(0, 100) + '...',
maxAutoReply,
pluginId: pluginConfig.id,
});
// Set actions to continue round with custom user message
// Set actions to continue round
const responseContext = context as ResponseHookContext;
if (!responseContext.actions) {
responseContext.actions = {};
}
responseContext.actions.yieldNextRoundTo = 'self'; // Continue with AI
responseContext.actions.newUserMessage = text; // Use custom message
// Store config for responseComplete hook (using a unique key based on context)
const contextKey = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
autoReplyConfigs.set(contextKey, { text, targetId });
// Store the key in metadata for retrieval
responseContext.metadata = {
...responseContext.metadata,
autoReplyKey: contextKey,
};
callback();
} catch (error) {
logger.error('Auto reply plugin error', error);
logger.error('Auto reply plugin error in postProcess', error);
callback();
}
});
// Second pass: Add auto reply message in responseComplete
hooks.responseComplete.tapAsync('autoReplyPlugin-execute', async (context, callback) => {
try {
const { handlerContext, response } = context;
if (response.status !== 'done' || !response.content) {
callback();
return;
}
// Find auto reply config by checking recent messages metadata
let autoReplyConfig: { text: string; targetId: string } | undefined;
let configKey: string | undefined;
// Check if any recent message has autoReplyKey in metadata
for (const config of autoReplyConfigs.entries()) {
configKey = config[0];
autoReplyConfig = config[1];
break; // Use the first/most recent config
}
if (!autoReplyConfig || !configKey) {
callback();
return;
}
const { text, targetId } = autoReplyConfig;
// Add the auto reply message directly to agent history
const autoReplyMessage: AgentInstanceMessage = {
id: `auto-reply-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
agentId: handlerContext.agent.id,
role: 'user',
content: text,
modified: new Date(),
duration: 1, // Auto reply messages are only visible to AI for 1 round
metadata: {
isAutoReply: true,
targetId,
},
};
handlerContext.agent.messages.push(autoReplyMessage);
// Save auto reply message to database
try {
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
await agentInstanceService.saveUserMessage(autoReplyMessage);
} catch (saveError) {
logger.warn('Failed to save auto reply message to database', {
error: saveError instanceof Error ? saveError.message : String(saveError),
messageId: autoReplyMessage.id,
});
}
// Clean up the config
autoReplyConfigs.delete(configKey);
logger.debug('Auto reply message added', {
messageId: autoReplyMessage.id,
content: text.substring(0, 100) + '...',
agentId: handlerContext.agent.id,
});
callback();
} catch (error) {
logger.error('Auto reply plugin error in responseComplete', error);
callback();
}
});

View file

@ -1,59 +1,100 @@
import { ToolCallingMatch } from '@services/agentDefinition/interface';
import type { AgentHandlerContext } from '@services/agentInstance/buildInAgentHandlers/type';
import { AgentHandlerContext } from '@services/agentInstance/buildInAgentHandlers/type';
import { AgentInstanceMessage } from '@services/agentInstance/interface';
import { AIStreamResponse } from '@services/externalAPI/interface';
import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable';
import type { IPrompt, Plugin } from '../promptConcat/promptConcatSchema/';
/**
* Context passed to plugin hooks
*/
export interface PromptConcatHookContext {
/** Array of agent instance messages for context */
messages: AgentInstanceMessage[];
/** Current prompt tree */
prompts: IPrompt[];
/** Plugin configuration */
pluginConfig: Plugin;
/** Additional context data */
metadata?: Record<string, unknown>;
}
/**
* Agent response interface
* Represents a structured response from an agent
*/
export interface AgentResponse {
id: string;
text?: string;
enabled?: boolean;
children?: AgentResponse[];
}
/**
* Next round target options
*/
export type YieldNextRoundTarget = 'human' | 'self' | `agent:${string}`; // allows for future agent IDs like "agent:agent-id"
/**
* Context for response processing hooks
* Unified actions interface for all plugin hooks
*/
export interface ResponseHookContext extends PromptConcatHookContext {
export interface PluginActions {
/** Whether to yield next round to continue processing */
yieldNextRoundTo?: YieldNextRoundTarget;
/** New user message to append */
newUserMessage?: string;
/** Tool calling information */
toolCalling?: ToolCallingMatch;
}
/**
* Base context interface for all plugin hooks
*/
export interface BasePluginContext {
/** Handler context */
handlerContext: AgentHandlerContext;
/** Additional context data */
metadata?: Record<string, unknown>;
/** Actions set by plugins during processing */
actions?: PluginActions;
}
/**
* Context for prompt processing hooks (processPrompts, finalizePrompts)
*/
export interface PromptConcatHookContext extends BasePluginContext {
/** Array of agent instance messages for context */
messages: AgentInstanceMessage[];
/** Current prompt tree */
prompts: IPrompt[];
/** Plugin configuration */
pluginConfig: Plugin;
}
/**
* Context for post-processing hooks
*/
export interface PostProcessContext extends PromptConcatHookContext {
/** LLM response text */
llmResponse: string;
responses: AgentResponse[];
actions?: {
yieldNextRoundTo?: YieldNextRoundTarget;
newUserMessage?: string;
toolCalling?: ToolCallingMatch;
/** Processed agent responses */
responses?: AgentResponse[];
}
/**
* Context for AI response hooks (responseUpdate, responseComplete)
*/
export interface AIResponseContext extends BasePluginContext {
/** AI streaming response */
response: AIStreamResponse;
/** Current request ID */
requestId?: string;
/** Whether this is the final response */
isFinal?: boolean;
}
/**
* Context for user message hooks
*/
export interface UserMessageContext extends BasePluginContext {
/** User message content */
content: { text: string; file?: File };
/** Generated message ID */
messageId: string;
/** Timestamp for the message */
timestamp: Date;
}
/**
* Context for agent status hooks
*/
export interface AgentStatusContext extends BasePluginContext {
/** New status state */
status: {
state: 'working' | 'completed' | 'failed' | 'canceled';
modified: Date;
};
}
/**
* Tool execution result context for handler hooks
* Context for tool execution hooks
*/
export interface ToolExecutionContext {
/** Handler context */
handlerContext: AgentHandlerContext;
export interface ToolExecutionContext extends BasePluginContext {
/** Tool execution result */
toolResult: {
success: boolean;
@ -72,44 +113,22 @@ export interface ToolExecutionContext {
}
/**
* AI Response context for streaming updates
* Agent response interface
* Represents a structured response from an agent
*/
export interface AIResponseContext {
/** Handler context */
handlerContext: AgentHandlerContext;
/** AI streaming response */
response: AIStreamResponse;
/** Current request ID */
requestId?: string;
/** Whether this is the final response */
isFinal: boolean;
export interface AgentResponse {
id: string;
text?: string;
enabled?: boolean;
children?: AgentResponse[];
}
/**
* User message context for new message arrival
* Context for response processing hooks (legacy support)
*/
export interface UserMessageContext {
/** Handler context */
handlerContext: AgentHandlerContext;
/** User message content */
content: { text: string; file?: File };
/** Generated message ID */
messageId: string;
/** Timestamp for the message */
timestamp: Date;
}
/**
* Agent status context for status updates
*/
export interface AgentStatusContext {
/** Handler context */
handlerContext: AgentHandlerContext;
/** New status state */
status: {
state: 'working' | 'completed' | 'failed' | 'canceled';
modified: Date;
};
export interface ResponseHookContext extends PromptConcatHookContext {
llmResponse: string;
responses: AgentResponse[];
}
/**
@ -122,7 +141,7 @@ export interface PromptConcatHooks {
/** Called to finalize prompts before LLM call */
finalizePrompts: AsyncSeriesWaterfallHook<[PromptConcatHookContext]>;
/** Called for post-processing after LLM response */
postProcess: AsyncSeriesWaterfallHook<[PromptConcatHookContext & { llmResponse: string }]>;
postProcess: AsyncSeriesWaterfallHook<[PostProcessContext]>;
/** Called when user sends a new message */
userMessageReceived: AsyncSeriesHook<[UserMessageContext]>;
/** Called when agent status changes */

View file

@ -6,6 +6,7 @@ import { z } from 'zod/v4';
import { WikiChannel } from '@/constants/channels';
import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility';
import type { IAgentInstanceService } from '@services/agentInstance/interface';
import { container } from '@services/container';
import { logger } from '@services/libs/log';
import serviceIdentifier from '@services/serviceIdentifier';
@ -14,9 +15,10 @@ import { IWorkspaceService } from '@services/workspaces/interface';
import { isWikiWorkspace } from '@services/workspaces/interface';
import type { ITiddlerFields } from 'tiddlywiki';
import type { AgentInstanceMessage } from '../interface';
import { findPromptById } from '../promptConcat/promptConcat';
import type { IPrompt } from '../promptConcat/promptConcatSchema';
import type { ResponseHookContext, PromptConcatPlugin } from './types';
import type { AIResponseContext, PromptConcatPlugin } from './types';
/**
* Parameter schema for Wiki search tool
@ -131,15 +133,24 @@ async function executeWikiSearchTool(
// Format results as text
let content = `Wiki search completed successfully. Found ${tiddlerTitles.length} total results, showing ${results.length}:\n\n`;
for (const result of results) {
content += `**Tiddler: ${result.title}**\n\n`;
if (result.text) {
content += '```tiddlywiki\n';
content += result.text;
content += '\n```\n\n';
} else {
content += '(Content not available)\n\n';
if (includeText) {
// Format with content
for (const result of results) {
content += `**Tiddler: ${result.title}**\n\n`;
if (result.text) {
content += '```tiddlywiki\n';
content += result.text;
content += '\n```\n\n';
} else {
content += '(Content not available)\n\n';
}
}
} else {
// Format titles only
for (const result of results) {
content += `- ${result.title}\n`;
}
content += '\n(includeText set to false)\n';
}
return {
@ -209,7 +220,7 @@ export const wikiSearchPlugin: PromptConcatPlugin = (hooks) => {
.join('\n');
const toolPromptContent =
`Available Wiki Workspaces:\n${workspaceList}\n\nAvailable Tools:\n- Tool ID: wiki-search\n- Tool Name: Wiki Search\n- Description: Search content in wiki workspaces\n- Parameters: {\n "workspaceName": "string (required) - The name of the wiki workspace to search in",\n "filter": "string (required) - TiddlyWiki filter expression for searching",\n "maxResults": "number (optional, default: 10) - Maximum number of results to return",\n "includeText": "boolean (optional, default: true) - Whether to include tiddler text content"\n}`;
`Available Wiki Workspaces:\n${workspaceList}\n\nAvailable Tools:\n- Tool ID: wiki-search\n- Tool Name: Wiki Search\n- Description: Search content in wiki workspaces\n- Parameters: {\n "workspaceName": "string (required) - The name of the wiki workspace to search in",\n "filter": "string (required) - TiddlyWiki filter expression for searching, like [title[Index]]""\n}`;
const toolPrompt: IPrompt = {
id: `wiki-tool-list-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
@ -268,6 +279,26 @@ export const wikiSearchPlugin: PromptConcatPlugin = (hooks) => {
agentId: handlerContext.agent.id,
});
// Set duration=1 for the AI message containing the tool call
// Find the most recent AI message (should be the one containing the tool call)
const aiMessages = handlerContext.agent.messages.filter(message => message.role === 'assistant');
if (aiMessages.length > 0) {
const latestAiMessage = aiMessages[aiMessages.length - 1];
if (latestAiMessage.content === response.content) {
latestAiMessage.duration = 1;
latestAiMessage.metadata = {
...latestAiMessage.metadata,
containsToolCall: true,
toolId: 'wiki-search',
};
logger.debug('Set duration=1 for AI tool call message', {
messageId: latestAiMessage.id,
toolId: 'wiki-search',
});
}
}
// Execute the wiki search tool call
try {
// Validate parameters against schema
@ -300,21 +331,65 @@ export const wikiSearchPlugin: PromptConcatPlugin = (hooks) => {
// Format the tool result for display
let toolResultText: string;
let isError = false;
if (result.success && result.data) {
toolResultText = `<functions_result>\nTool: wiki-search\nParameters: ${JSON.stringify(validatedParameters)}\nResult: ${result.data}\n</functions_result>`;
} else {
isError = true;
toolResultText = `<functions_result>\nTool: wiki-search\nParameters: ${JSON.stringify(validatedParameters)}\nError: ${
result.error || 'Unknown error'
}\n</functions_result>`;
}
// Set up actions to continue the conversation with tool results
const responseContext = context as unknown as ResponseHookContext;
const responseContext = context as unknown as AIResponseContext;
if (!responseContext.actions) {
responseContext.actions = {};
}
responseContext.actions.yieldNextRoundTo = 'self';
responseContext.actions.newUserMessage = toolResultText;
logger.debug('Wiki search setting yieldNextRoundTo=self', {
toolId: 'wiki-search',
agentId: handlerContext.agent.id,
messageCount: handlerContext.agent.messages.length,
});
// Immediately add the tool result message to history
// Use a slight delay to ensure timestamp is after the tool call message
const toolResultTime = new Date(Date.now() + 1); // Add 1ms to ensure proper ordering
const toolResultMessage: AgentInstanceMessage = {
id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
agentId: handlerContext.agent.id,
role: 'user',
content: toolResultText,
modified: toolResultTime,
duration: 1, // Tool results are only visible to AI for 1 round to save context
metadata: {
isToolResult: true,
isError,
toolId: 'wiki-search',
toolParameters: validatedParameters,
},
};
handlerContext.agent.messages.push(toolResultMessage);
// Save tool result message to database
try {
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
await agentInstanceService.saveUserMessage(toolResultMessage);
} catch (saveError) {
logger.warn('Failed to save tool result message to database', {
error: saveError instanceof Error ? saveError.message : String(saveError),
messageId: toolResultMessage.id,
});
}
logger.debug('Wiki search tool execution result', {
toolResultText,
actions: responseContext.actions,
toolResultMessageId: toolResultMessage.id,
});
} catch (error) {
logger.error('Wiki search tool execution failed', {
error: error instanceof Error ? error.message : String(error),
@ -322,15 +397,44 @@ export const wikiSearchPlugin: PromptConcatPlugin = (hooks) => {
});
// Set up error response for next round
const responseContext = context as unknown as ResponseHookContext;
const responseContext = context as unknown as AIResponseContext;
if (!responseContext.actions) {
responseContext.actions = {};
}
responseContext.actions.yieldNextRoundTo = 'self';
responseContext.actions.newUserMessage = `<functions_result>
const errorMessage = `<functions_result>
Tool: wiki-search
Error: ${error instanceof Error ? error.message : String(error)}
</functions_result>`;
// Add error message to history
// Use a slight delay to ensure timestamp is after the tool call message
const errorResultTime = new Date(Date.now() + 1); // Add 1ms to ensure proper ordering
const errorResultMessage: AgentInstanceMessage = {
id: `tool-error-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
agentId: handlerContext.agent.id,
role: 'user',
content: errorMessage,
modified: errorResultTime,
duration: 1, // Error messages are only visible to AI for 1 round
metadata: {
isToolResult: true,
isError: true,
toolId: 'wiki-search',
},
};
handlerContext.agent.messages.push(errorResultMessage);
// Save error message to database
try {
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
await agentInstanceService.saveUserMessage(errorResultMessage);
} catch (saveError) {
logger.warn('Failed to save tool error message to database', {
error: saveError instanceof Error ? saveError.message : String(saveError),
messageId: errorResultMessage.id,
});
}
}
callback();

View file

@ -16,6 +16,7 @@
import { logger } from '@services/libs/log';
import { CoreMessage } from 'ai';
import { cloneDeep } from 'lodash';
import { AgentHandlerContext } from '../buildInAgentHandlers/type';
import { AgentInstanceMessage } from '../interface';
import { builtInPlugins, createHandlerHooks, initializePluginSystem, PromptConcatHookContext } from '../plugins';
import { AgentPromptDescription, IPrompt } from './promptConcatSchema';
@ -216,6 +217,7 @@ export interface PromptConcatStreamState {
export async function* promptConcatStream(
agentConfig: Pick<AgentPromptDescription, 'handlerConfig'>,
messages: AgentInstanceMessage[],
handlerContext: AgentHandlerContext,
): AsyncGenerator<PromptConcatStreamState, PromptConcatStreamState, unknown> {
const promptConfigs = Array.isArray(agentConfig.handlerConfig.prompts) ? agentConfig.handlerConfig.prompts : [];
const pluginConfigs = Array.isArray(agentConfig.handlerConfig.plugins) ? agentConfig.handlerConfig.plugins : [];
@ -243,6 +245,7 @@ export async function* promptConcatStream(
for (let index = 0; index < pluginConfigs.length; index++) {
const context: PromptConcatHookContext = {
handlerContext,
messages,
prompts: modifiedPrompts,
pluginConfig: pluginConfigs[index],
@ -287,6 +290,7 @@ export async function* promptConcatStream(
};
const finalContext: PromptConcatHookContext = {
handlerContext,
messages,
prompts: modifiedPrompts,
pluginConfig: {} as Plugin, // Empty plugin for finalization
@ -345,17 +349,19 @@ export async function* promptConcatStream(
*
* @param agentConfig Prompt configuration
* @param messages Message history
* @param handlerContext Handler context with agent and other state
* @returns Processed prompt array and original prompt tree
*/
export async function promptConcat(
agentConfig: Pick<AgentPromptDescription, 'handlerConfig'>,
messages: AgentInstanceMessage[],
handlerContext: AgentHandlerContext,
): Promise<{
flatPrompts: CoreMessage[];
processedPrompts: IPrompt[];
}> {
// Use the streaming version and just return the final result
const stream = promptConcatStream(agentConfig, messages);
const stream = promptConcatStream(agentConfig, messages, handlerContext);
let finalResult: PromptConcatStreamState;
// Consume all intermediate states to get the final result

View file

@ -115,10 +115,6 @@ export const AutoReplyParameterSchema = z.object({
title: t('Schema.AutoReply.TextTitle'),
description: t('Schema.AutoReply.Text'),
}),
maxAutoReply: z.number().optional().meta({
title: t('Schema.AutoReply.MaxAutoReplyTitle'),
description: t('Schema.AutoReply.MaxAutoReply'),
}),
}).meta({
title: t('Schema.AutoReply.Title'),
description: t('Schema.AutoReply.Description'),

View file

@ -9,7 +9,7 @@ import { cloneDeep } from 'lodash';
import { AgentHandlerContext } from '../buildInAgentHandlers/type';
import { AgentInstanceMessage } from '../interface';
import { builtInPlugins, createHandlerHooks } from '../plugins';
import { AgentResponse, ResponseHookContext } from '../plugins/types';
import { AgentResponse, PostProcessContext, YieldNextRoundTarget } from '../plugins/types';
import { AgentPromptDescription, HandlerConfig } from './promptConcatSchema';
/**
@ -27,8 +27,7 @@ export async function responseConcat(
messages: AgentInstanceMessage[] = [],
): Promise<{
processedResponse: string;
needsNewLLMCall: boolean;
newUserMessage?: string;
yieldNextRoundTo?: YieldNextRoundTarget;
toolCallInfo?: ToolCallingMatch;
}> {
logger.debug('Starting response processing', {
@ -42,9 +41,7 @@ export async function responseConcat(
const responses: HandlerConfig['response'] = Array.isArray(handlerConfig.response) ? handlerConfig.response : [];
const plugins = Array.isArray(handlerConfig.plugins) ? handlerConfig.plugins : [];
const responsesCopy = cloneDeep(responses);
let modifiedResponses: AgentResponse[] = responsesCopy;
let modifiedResponses = cloneDeep(responses) as AgentResponse[];
// Create hooks instance
const hooks = createHandlerHooks();
// Register all plugins from configuration
@ -58,12 +55,12 @@ export async function responseConcat(
}
// Process each plugin through hooks
let needsNewLLMCall = false;
let newUserMessage: string | undefined;
let yieldNextRoundTo: YieldNextRoundTarget | undefined;
let toolCallInfo: ToolCallingMatch | undefined;
for (const plugin of plugins) {
const responseContext: ResponseHookContext = {
const responseContext: PostProcessContext = {
handlerContext: context,
messages,
prompts: [], // Not used in response processing
pluginConfig: plugin,
@ -73,24 +70,23 @@ export async function responseConcat(
};
try {
const result = await hooks.postProcess.promise(responseContext) as ResponseHookContext;
const result = await hooks.postProcess.promise(responseContext);
// Update responses if they were modified in the context
modifiedResponses = result.responses;
if (result.responses) {
modifiedResponses = result.responses;
}
// Check if plugin indicated need for new LLM call via actions
if (result.actions?.yieldNextRoundTo === 'self') {
needsNewLLMCall = true;
if (result.actions.newUserMessage) {
newUserMessage = result.actions.newUserMessage;
}
if (result.actions?.yieldNextRoundTo) {
yieldNextRoundTo = result.actions.yieldNextRoundTo;
if (result.actions.toolCalling) {
toolCallInfo = result.actions.toolCalling;
}
logger.debug('Plugin requested yield next round to self', {
logger.debug('Plugin requested yield next round', {
pluginId: plugin.pluginId,
pluginInstanceId: plugin.id,
hasNewUserMessage: !!result.actions.newUserMessage,
yieldNextRoundTo,
hasToolCall: !!result.actions.toolCalling,
});
}
@ -114,14 +110,12 @@ export async function responseConcat(
logger.debug('Response processing completed', {
originalLength: llmResponse.length,
processedLength: processedResponse.length,
needsNewLLMCall,
hasNewUserMessage: !!newUserMessage,
yieldNextRoundTo,
});
return {
processedResponse,
needsNewLLMCall,
newUserMessage,
yieldNextRoundTo,
toolCallInfo,
};
}

View file

@ -59,7 +59,7 @@ export function createAgentInstanceData(agentDefinition: {
export function createAgentMessage(
id: string,
agentId: string,
message: Pick<AgentInstanceMessage, 'role' | 'content' | 'contentType' | 'metadata'>,
message: Pick<AgentInstanceMessage, 'role' | 'content' | 'contentType' | 'metadata' | 'duration'>,
): AgentInstanceMessage {
return {
id,
@ -69,13 +69,40 @@ export function createAgentMessage(
contentType: message.contentType || 'text/plain',
modified: new Date(),
metadata: message.metadata,
// Convert null to undefined for database compatibility
duration: message.duration === null ? undefined : message.duration,
};
}
/**
* Message fields to be extracted when creating message entities
*/
export const MESSAGE_FIELDS = ['id', 'agentId', 'role', 'content', 'contentType', 'metadata'] as const;
export const MESSAGE_FIELDS = ['id', 'agentId', 'role', 'content', 'contentType', 'metadata', 'duration'] as const;
/**
* Convert AgentInstanceMessage to database-compatible format
* Handles null duration values by converting them to undefined
*/
export function toDatabaseCompatibleMessage(message: AgentInstanceMessage): Omit<AgentInstanceMessage, 'duration'> & { duration?: number } {
const { duration, ...rest } = message;
return {
...rest,
duration: duration === null ? undefined : duration,
};
}
/**
* Convert AgentInstance data to database-compatible format
* Handles null duration values in messages by converting them to undefined
*/
export function toDatabaseCompatibleInstance(
instance: Omit<AgentInstance, 'created' | 'modified'>,
): Omit<AgentInstance, 'created' | 'modified' | 'messages'> & { messages: Array<Omit<AgentInstanceMessage, 'duration'> & { duration?: number }> } {
return {
...instance,
messages: instance.messages.map(toDatabaseCompatibleMessage),
};
}
/**
* Agent instance fields to be extracted when retrieving instances

View file

@ -0,0 +1,121 @@
/**
* Test for message duration filtering functionality
*/
import { describe, expect, it } from 'vitest';
import type { AgentInstanceMessage } from '../../interface';
import { filterMessagesByDuration, isMessageExpiredForAI } from '../messageDurationFilter';
// Helper function to create test messages
function createTestMessage(
id: string,
role: 'user' | 'assistant' = 'user',
duration?: number,
): AgentInstanceMessage {
return {
id,
agentId: 'test-agent',
role,
content: `Test message ${id}`,
modified: new Date(),
duration,
};
}
describe('Message Duration Filtering', () => {
describe('filterMessagesByDuration', () => {
it('should return all messages when no duration is set', () => {
const messages = [
createTestMessage('1'),
createTestMessage('2'),
createTestMessage('3'),
];
const filtered = filterMessagesByDuration(messages);
expect(filtered).toHaveLength(3);
expect(filtered.map(m => m.id)).toEqual(['1', '2', '3']);
});
it('should exclude messages with duration 0', () => {
const messages = [
createTestMessage('1', 'user', undefined), // Keep
createTestMessage('2', 'user', 0), // Exclude
createTestMessage('3', 'user', 1), // Keep (within duration)
];
const filtered = filterMessagesByDuration(messages);
expect(filtered).toHaveLength(2);
expect(filtered.map(m => m.id)).toEqual(['1', '3']);
});
it('should respect duration limits based on rounds from current', () => {
const messages = [
createTestMessage('1', 'user', 1), // Round 2 from current (exclude)
createTestMessage('2', 'assistant', 1), // Round 1 from current (exclude)
createTestMessage('3', 'user', 1), // Round 0 from current (include)
];
const filtered = filterMessagesByDuration(messages);
expect(filtered).toHaveLength(1);
expect(filtered.map(m => m.id)).toEqual(['3']);
});
it('should handle mixed duration settings correctly', () => {
const messages = [
createTestMessage('1', 'user', undefined), // Always keep (undefined duration)
createTestMessage('2', 'assistant', 5), // Keep (round 3 < duration 5)
createTestMessage('3', 'user', 3), // Keep (round 2 < duration 3)
createTestMessage('4', 'assistant', 1), // Exclude (round 1 >= duration 1)
createTestMessage('5', 'user', 0), // Exclude (duration 0)
];
const filtered = filterMessagesByDuration(messages);
expect(filtered).toHaveLength(3);
expect(filtered.map(m => m.id)).toEqual(['1', '2', '3']);
});
it('should return empty array for empty input', () => {
const filtered = filterMessagesByDuration([]);
expect(filtered).toHaveLength(0);
});
});
describe('isMessageExpiredForAI', () => {
it('should return false for messages with undefined duration', () => {
const message = createTestMessage('1', 'user', undefined);
expect(isMessageExpiredForAI(message, 0, 3)).toBe(false);
expect(isMessageExpiredForAI(message, 2, 3)).toBe(false);
});
it('should return true for messages with duration 0', () => {
const message = createTestMessage('1', 'user', 0);
expect(isMessageExpiredForAI(message, 0, 3)).toBe(true);
expect(isMessageExpiredForAI(message, 2, 3)).toBe(true);
});
it('should correctly calculate expiration based on position and duration', () => {
const message = createTestMessage('1', 'user', 2);
// Position 2 in array of 3: rounds from current = 3-1-2 = 0 (< 2, not expired)
expect(isMessageExpiredForAI(message, 2, 3)).toBe(false);
// Position 1 in array of 3: rounds from current = 3-1-1 = 1 (< 2, not expired)
expect(isMessageExpiredForAI(message, 1, 3)).toBe(false);
// Position 0 in array of 3: rounds from current = 3-1-0 = 2 (>= 2, expired)
expect(isMessageExpiredForAI(message, 0, 3)).toBe(true);
});
it('should handle edge cases correctly', () => {
const message = createTestMessage('1', 'user', 1);
// Single message array
expect(isMessageExpiredForAI(message, 0, 1)).toBe(false);
// Last message in array
expect(isMessageExpiredForAI(message, 4, 5)).toBe(false);
// First message in large array
expect(isMessageExpiredForAI(message, 0, 5)).toBe(true);
});
});
});

View file

@ -0,0 +1,74 @@
/**
* Message filtering utilities for duration-based context management
*/
import type { AgentInstanceMessage } from '../interface';
/**
* Filter messages based on their duration settings
* Messages with duration set will only be included if they are within the specified number of rounds from the current position
* @param messages Array of all messages
* @returns Filtered array containing only messages that should be sent to AI
*/
export function filterMessagesByDuration(messages: AgentInstanceMessage[]): AgentInstanceMessage[] {
// If no messages, return empty array
if (messages.length === 0) return [];
// Calculate the current round position (how many rounds have passed since each message)
const filteredMessages: AgentInstanceMessage[] = [];
// Iterate through messages from latest to oldest to calculate rounds
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
// Calculate rounds from current position (0 = current message, 1 = previous round, etc.)
const roundsFromCurrent = messages.length - 1 - index;
// If duration is undefined or null, include the message (default behavior - persist indefinitely)
if (message.duration === undefined || message.duration === null) {
filteredMessages.unshift(message);
continue;
}
// If duration is 0, exclude from AI context (but still visible in UI)
if (message.duration === 0) {
continue;
}
// If message is within its duration window, include it
if (roundsFromCurrent < message.duration) {
filteredMessages.unshift(message);
}
// Otherwise, message has expired and should not be sent to AI
}
return filteredMessages;
}
/**
* Check if a message should be displayed with reduced opacity in UI
* @param message The message to check
* @param currentPosition The current position of the message in the full message array (0-based from start)
* @param totalMessages Total number of messages
* @returns true if the message should be semi-transparent
*/
export function isMessageExpiredForAI(
message: AgentInstanceMessage,
currentPosition: number,
totalMessages: number,
): boolean {
// If duration is undefined, message never expires
if (message.duration === undefined || message.duration === null) {
return false;
}
// If duration is 0, message is immediately expired
if (message.duration === 0) {
return true;
}
// Calculate rounds from current position
const roundsFromCurrent = totalMessages - 1 - currentPosition;
// Message is expired if it's beyond its duration window
return roundsFromCurrent >= message.duration;
}

View file

@ -140,6 +140,9 @@ export class AgentInstanceMessageEntity implements AgentInstanceMessage {
@Column({ type: 'simple-json', nullable: true, name: 'meta_data' })
metadata?: Record<string, unknown>;
@Column({ type: 'integer', nullable: true })
duration?: number;
// Relation to AgentInstance
@ManyToOne(() => AgentInstanceEntity, instance => instance.messages)
@JoinColumn({ name: 'agentId' })

View file

@ -155,6 +155,7 @@ export class ExternalAPIService implements IExternalAPIService {
): AsyncGenerator<AIStreamResponse, void, unknown> {
// Prepare request with minimal context
const { requestId, controller } = this.prepareAIRequest();
logger.debug(`[${requestId}] Starting generateFromAI with config`, messages);
try {
// Send start event