From d2a37dac87f8f3de6c98154ca7494764e4bddec2 Mon Sep 17 00:00:00 2001 From: lin onetwo Date: Fri, 1 Aug 2025 01:24:48 +0800 Subject: [PATCH] refactor: less plugins --- src/helpers/__tests__/url.test.ts | 4 +- .../components/MessageBubble.tsx | 35 +++- .../components/PromptPreviewDialog/index.tsx | 2 +- src/services/agentDefinition/index.ts | 2 +- .../promptConcatStreamIntegration.test.ts | 27 ++- .../basicPromptConcatHandler.ts | 54 +++--- .../buildInAgentHandlers/defaultAgents.json | 10 +- src/services/agentInstance/index.ts | 45 +++-- src/services/agentInstance/interface.ts | 7 + .../plugins/__tests__/pluginSystem.test.ts | 6 +- .../__tests__/wikiSearchPlugin.test.ts | 33 +++- .../plugins/aiResponseHistoryPlugin.ts | 141 --------------- src/services/agentInstance/plugins/index.ts | 125 +++++++------- ...cePlugin.ts => messageManagementPlugin.ts} | 83 ++++++--- .../agentInstance/plugins/promptPlugins.ts | 13 +- .../agentInstance/plugins/responsePlugins.ts | 103 ++++++++++- src/services/agentInstance/plugins/types.ts | 161 ++++++++++-------- .../agentInstance/plugins/wikiSearchPlugin.ts | 132 ++++++++++++-- .../promptConcat/promptConcat.ts | 8 +- .../promptConcat/promptConcatSchema/plugin.ts | 4 - .../promptConcat/responseConcat.ts | 38 ++--- src/services/agentInstance/utilities.ts | 31 +++- .../__tests__/messageDurationFilter.test.ts | 121 +++++++++++++ .../utilities/messageDurationFilter.ts | 74 ++++++++ src/services/database/schema/agent.ts | 3 + src/services/externalAPI/index.ts | 1 + 26 files changed, 836 insertions(+), 427 deletions(-) delete mode 100644 src/services/agentInstance/plugins/aiResponseHistoryPlugin.ts rename src/services/agentInstance/plugins/{persistencePlugin.ts => messageManagementPlugin.ts} (66%) create mode 100644 src/services/agentInstance/utilities/__tests__/messageDurationFilter.test.ts create mode 100644 src/services/agentInstance/utilities/messageDurationFilter.ts diff --git a/src/helpers/__tests__/url.test.ts b/src/helpers/__tests__/url.test.ts index d2dc16ac..4de57c18 100644 --- a/src/helpers/__tests__/url.test.ts +++ b/src/helpers/__tests__/url.test.ts @@ -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(); }); }); diff --git a/src/pages/ChatTabContent/components/MessageBubble.tsx b/src/pages/ChatTabContent/components/MessageBubble.tsx index 2ce09ff0..361801ea 100644 --- a/src/pages/ChatTabContent/components/MessageBubble.tsx +++ b/src/pages/ChatTabContent/components/MessageBubble.tsx @@ -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 = ({ 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 ( - + {!isUser && ( - + )} - + {isUser && ( - + )} diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/index.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/index.tsx index 7d1636b8..f74eb075 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/index.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/index.tsx @@ -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 { diff --git a/src/services/agentDefinition/index.ts b/src/services/agentDefinition/index.ts index 08022f90..f817cede 100644 --- a/src/services/agentDefinition/index.ts +++ b/src/services/agentDefinition/index.ts @@ -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}`); diff --git a/src/services/agentInstance/buildInAgentHandlers/__tests__/promptConcatStreamIntegration.test.ts b/src/services/agentInstance/buildInAgentHandlers/__tests__/promptConcatStreamIntegration.test.ts index b77547bf..952181dd 100644 --- a/src/services/agentInstance/buildInAgentHandlers/__tests__/promptConcatStreamIntegration.test.ts +++ b/src/services/agentInstance/buildInAgentHandlers/__tests__/promptConcatStreamIntegration.test.ts @@ -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(''); - 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(''); + 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'); }); }); }); diff --git a/src/services/agentInstance/buildInAgentHandlers/basicPromptConcatHandler.ts b/src/services/agentInstance/buildInAgentHandlers/basicPromptConcatHandler.ts index c449236c..f0ca95da 100644 --- a/src/services/agentInstance/buildInAgentHandlers/basicPromptConcatHandler.ts +++ b/src/services/agentInstance/buildInAgentHandlers/basicPromptConcatHandler.ts @@ -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(serviceIdentifier.AgentInstance); // Generate AI response // Function to process a single LLM call with retry support - async function* processLLMCall(_userMessage: string): AsyncGenerator { + async function* processLLMCall(): AsyncGenerator { 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', { diff --git a/src/services/agentInstance/buildInAgentHandlers/defaultAgents.json b/src/services/agentInstance/buildInAgentHandlers/defaultAgents.json index 52f4324e..59e69a1b 100644 --- a/src/services/agentInstance/buildInAgentHandlers/defaultAgents.json +++ b/src/services/agentInstance/buildInAgentHandlers/defaultAgents.json @@ -122,15 +122,7 @@ "pluginId": "autoReply", "autoReplyParam": { "targetId": "default-response", - "text": "继续工作直到你自己觉得工作已经完全完成。", - "trigger": { - "model": { - "preset": "defaultLite", - "system": "你是一个对话分析师,你将对目前的对话进行分析,确定AI的回复是否完全解决了用户的问题。一般来说,如果AI的回复只解决了用户提出的问题的一个子问题,那么就需要继续工作。请注意,你需要结合上下文进行分析,而不是仅仅依赖于AI的回复内容。请不要做出1或者0之外的回答,回答中也不要包括其他文本内容。", - "user": "用户的消息内容为:<>。如果此用户消息需要进行网络搜索,请回答1,如果不需要,请回答0。请不要做出1或者0之外的回答,回答中也不要包括其他文本内容。" - } - }, - "maxAutoReply": 5 + "text": "继续工作直到你自己觉得工作已经完全完成。如果根据之前的对话你认为任务已完成,则总结并结束对话。如果任务还未完成,你可以继续调用工具。" } } ] diff --git a/src/services/agentInstance/index.ts b/src/services/agentInstance/index.ts index bcd99bc7..654ed30f 100644 --- a/src/services/agentInstance/index.ts +++ b/src/services/agentInstance/index.ts @@ -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 { 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((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) { diff --git a/src/services/agentInstance/interface.ts b/src/services/agentInstance/interface.ts index bcde5dee..ab129df1 100644 --- a/src/services/agentInstance/interface.ts +++ b/src/services/agentInstance/interface.ts @@ -90,6 +90,13 @@ export interface AgentInstanceMessage { metadata?: Record; /** 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; } /** diff --git a/src/services/agentInstance/plugins/__tests__/pluginSystem.test.ts b/src/services/agentInstance/plugins/__tests__/pluginSystem.test.ts index 6a5015bf..eb934e35 100644 --- a/src/services/agentInstance/plugins/__tests__/pluginSystem.test.ts +++ b/src/services/agentInstance/plugins/__tests__/pluginSystem.test.ts @@ -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(); }); }); diff --git a/src/services/agentInstance/plugins/__tests__/wikiSearchPlugin.test.ts b/src/services/agentInstance/plugins/__tests__/wikiSearchPlugin.test.ts index 39b4c653..b943af6f 100644 --- a/src/services/agentInstance/plugins/__tests__/wikiSearchPlugin.test.ts +++ b/src/services/agentInstance/plugins/__tests__/wikiSearchPlugin.test.ts @@ -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(''); - 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(''); + 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(''); - 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(''); + 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 () => { diff --git a/src/services/agentInstance/plugins/aiResponseHistoryPlugin.ts b/src/services/agentInstance/plugins/aiResponseHistoryPlugin.ts deleted file mode 100644 index 2fe7e778..00000000 --- a/src/services/agentInstance/plugins/aiResponseHistoryPlugin.ts +++ /dev/null @@ -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(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(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(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(); - } - }); -}; diff --git a/src/services/agentInstance/plugins/index.ts b/src/services/agentInstance/plugins/index.ts index 7aa9accb..db6b3650 100644 --- a/src/services/agentInstance/plugins/index.ts +++ b/src/services/agentInstance/plugins/index.ts @@ -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(); } diff --git a/src/services/agentInstance/plugins/persistencePlugin.ts b/src/services/agentInstance/plugins/messageManagementPlugin.ts similarity index 66% rename from src/services/agentInstance/plugins/persistencePlugin.ts rename to src/services/agentInstance/plugins/messageManagementPlugin.ts index 75f5baaa..57fb29c4 100644 --- a/src/services/agentInstance/plugins/persistencePlugin.ts +++ b/src/services/agentInstance/plugins/messageManagementPlugin.ts @@ -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(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(); diff --git a/src/services/agentInstance/plugins/promptPlugins.ts b/src/services/agentInstance/plugins/promptPlugins.ts index 2c5bf056..5ed4dc9f 100644 --- a/src/services/agentInstance/plugins/promptPlugins.ts +++ b/src/services/agentInstance/plugins/promptPlugins.ts @@ -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; const role: PromptRole = message.role === 'agent' diff --git a/src/services/agentInstance/plugins/responsePlugins.ts b/src/services/agentInstance/plugins/responsePlugins.ts index d4804dc2..a99127ef 100644 --- a/src/services/agentInstance/plugins/responsePlugins.ts +++ b/src/services/agentInstance/plugins/responsePlugins.ts @@ -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(); + + // 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(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(); } }); diff --git a/src/services/agentInstance/plugins/types.ts b/src/services/agentInstance/plugins/types.ts index 990384f6..016ba45c 100644 --- a/src/services/agentInstance/plugins/types.ts +++ b/src/services/agentInstance/plugins/types.ts @@ -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; -} - -/** - * 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; + /** 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 */ diff --git a/src/services/agentInstance/plugins/wikiSearchPlugin.ts b/src/services/agentInstance/plugins/wikiSearchPlugin.ts index f9cb9678..0fd5a845 100644 --- a/src/services/agentInstance/plugins/wikiSearchPlugin.ts +++ b/src/services/agentInstance/plugins/wikiSearchPlugin.ts @@ -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 = `\nTool: wiki-search\nParameters: ${JSON.stringify(validatedParameters)}\nResult: ${result.data}\n`; } else { + isError = true; toolResultText = `\nTool: wiki-search\nParameters: ${JSON.stringify(validatedParameters)}\nError: ${ result.error || 'Unknown error' }\n`; } // 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(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 = ` + const errorMessage = ` Tool: wiki-search Error: ${error instanceof Error ? error.message : String(error)} `; + + // 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(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(); diff --git a/src/services/agentInstance/promptConcat/promptConcat.ts b/src/services/agentInstance/promptConcat/promptConcat.ts index 79a0eb54..66c1ca18 100644 --- a/src/services/agentInstance/promptConcat/promptConcat.ts +++ b/src/services/agentInstance/promptConcat/promptConcat.ts @@ -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, messages: AgentInstanceMessage[], + handlerContext: AgentHandlerContext, ): AsyncGenerator { 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, 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 diff --git a/src/services/agentInstance/promptConcat/promptConcatSchema/plugin.ts b/src/services/agentInstance/promptConcat/promptConcatSchema/plugin.ts index 074b7c51..c6e6513a 100644 --- a/src/services/agentInstance/promptConcat/promptConcatSchema/plugin.ts +++ b/src/services/agentInstance/promptConcat/promptConcatSchema/plugin.ts @@ -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'), diff --git a/src/services/agentInstance/promptConcat/responseConcat.ts b/src/services/agentInstance/promptConcat/responseConcat.ts index 2e1202fc..db0b777a 100644 --- a/src/services/agentInstance/promptConcat/responseConcat.ts +++ b/src/services/agentInstance/promptConcat/responseConcat.ts @@ -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, }; } diff --git a/src/services/agentInstance/utilities.ts b/src/services/agentInstance/utilities.ts index 595f8a86..883b980c 100644 --- a/src/services/agentInstance/utilities.ts +++ b/src/services/agentInstance/utilities.ts @@ -59,7 +59,7 @@ export function createAgentInstanceData(agentDefinition: { export function createAgentMessage( id: string, agentId: string, - message: Pick, + message: Pick, ): 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 & { 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, +): Omit & { messages: Array & { duration?: number }> } { + return { + ...instance, + messages: instance.messages.map(toDatabaseCompatibleMessage), + }; +} /** * Agent instance fields to be extracted when retrieving instances diff --git a/src/services/agentInstance/utilities/__tests__/messageDurationFilter.test.ts b/src/services/agentInstance/utilities/__tests__/messageDurationFilter.test.ts new file mode 100644 index 00000000..3417803e --- /dev/null +++ b/src/services/agentInstance/utilities/__tests__/messageDurationFilter.test.ts @@ -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); + }); + }); +}); diff --git a/src/services/agentInstance/utilities/messageDurationFilter.ts b/src/services/agentInstance/utilities/messageDurationFilter.ts new file mode 100644 index 00000000..7307ac67 --- /dev/null +++ b/src/services/agentInstance/utilities/messageDurationFilter.ts @@ -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; +} diff --git a/src/services/database/schema/agent.ts b/src/services/database/schema/agent.ts index abe81d88..972cfaca 100644 --- a/src/services/database/schema/agent.ts +++ b/src/services/database/schema/agent.ts @@ -140,6 +140,9 @@ export class AgentInstanceMessageEntity implements AgentInstanceMessage { @Column({ type: 'simple-json', nullable: true, name: 'meta_data' }) metadata?: Record; + @Column({ type: 'integer', nullable: true }) + duration?: number; + // Relation to AgentInstance @ManyToOne(() => AgentInstanceEntity, instance => instance.messages) @JoinColumn({ name: 'agentId' }) diff --git a/src/services/externalAPI/index.ts b/src/services/externalAPI/index.ts index 8c17598e..3d05d08a 100644 --- a/src/services/externalAPI/index.ts +++ b/src/services/externalAPI/index.ts @@ -155,6 +155,7 @@ export class ExternalAPIService implements IExternalAPIService { ): AsyncGenerator { // Prepare request with minimal context const { requestId, controller } = this.prepareAIRequest(); + logger.debug(`[${requestId}] Starting generateFromAI with config`, messages); try { // Send start event