mirror of
https://github.com/zadam/trilium.git
synced 2025-12-05 06:54:23 +01:00
feat(llm): get rid of now unused files
This commit is contained in:
parent
3db145b6e6
commit
a1e596b81b
@ -1,861 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ChatService } from './chat_service.js';
|
||||
import type { Message, ChatCompletionOptions } from './ai_interface.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./chat_storage_service.js', () => ({
|
||||
default: {
|
||||
createChat: vi.fn(),
|
||||
getChat: vi.fn(),
|
||||
updateChat: vi.fn(),
|
||||
deleteChat: vi.fn(),
|
||||
getAllChats: vi.fn(),
|
||||
recordSources: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('./constants/llm_prompt_constants.js', () => ({
|
||||
CONTEXT_PROMPTS: {
|
||||
NOTE_CONTEXT_PROMPT: 'Context: {context}',
|
||||
SEMANTIC_NOTE_CONTEXT_PROMPT: 'Query: {query}\nContext: {context}'
|
||||
},
|
||||
ERROR_PROMPTS: {
|
||||
USER_ERRORS: {
|
||||
GENERAL_ERROR: 'Sorry, I encountered an error processing your request.',
|
||||
CONTEXT_ERROR: 'Sorry, I encountered an error processing the context.'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('./pipeline/chat_pipeline.js', () => ({
|
||||
ChatPipeline: vi.fn().mockImplementation((config) => ({
|
||||
config,
|
||||
execute: vi.fn(),
|
||||
getMetrics: vi.fn(),
|
||||
resetMetrics: vi.fn(),
|
||||
stages: {
|
||||
contextExtraction: {
|
||||
execute: vi.fn()
|
||||
},
|
||||
semanticContextExtraction: {
|
||||
execute: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('./ai_service_manager.js', () => ({
|
||||
default: {
|
||||
getService: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('ChatService', () => {
|
||||
let chatService: ChatService;
|
||||
let mockChatStorageService: any;
|
||||
let mockAiServiceManager: any;
|
||||
let mockChatPipeline: any;
|
||||
let mockLog: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Get mocked modules
|
||||
mockChatStorageService = (await import('./chat_storage_service.js')).default;
|
||||
mockAiServiceManager = (await import('./ai_service_manager.js')).default;
|
||||
mockLog = (await import('../log.js')).default;
|
||||
|
||||
// Setup pipeline mock
|
||||
mockChatPipeline = {
|
||||
execute: vi.fn(),
|
||||
getMetrics: vi.fn(),
|
||||
resetMetrics: vi.fn(),
|
||||
stages: {
|
||||
contextExtraction: {
|
||||
execute: vi.fn()
|
||||
},
|
||||
semanticContextExtraction: {
|
||||
execute: vi.fn()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new ChatService instance
|
||||
chatService = new ChatService();
|
||||
|
||||
// Replace the internal pipelines with our mock
|
||||
(chatService as any).pipelines.set('default', mockChatPipeline);
|
||||
(chatService as any).pipelines.set('agent', mockChatPipeline);
|
||||
(chatService as any).pipelines.set('performance', mockChatPipeline);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with default pipelines', () => {
|
||||
expect(chatService).toBeDefined();
|
||||
// Verify pipelines are created by checking internal state
|
||||
expect((chatService as any).pipelines).toBeDefined();
|
||||
expect((chatService as any).sessionCache).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a new chat session with default title', async () => {
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.createChat.mockResolvedValueOnce(mockChat);
|
||||
|
||||
const session = await chatService.createSession();
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-123',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.createChat).toHaveBeenCalledWith('New Chat', []);
|
||||
});
|
||||
|
||||
it('should create a new chat session with custom title and messages', async () => {
|
||||
const initialMessages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' }
|
||||
];
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-456',
|
||||
title: 'Custom Chat',
|
||||
messages: initialMessages,
|
||||
noteId: 'chat-456',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.createChat.mockResolvedValueOnce(mockChat);
|
||||
|
||||
const session = await chatService.createSession('Custom Chat', initialMessages);
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-456',
|
||||
title: 'Custom Chat',
|
||||
messages: initialMessages,
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.createChat).toHaveBeenCalledWith('Custom Chat', initialMessages);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrCreateSession', () => {
|
||||
it('should return cached session if available', async () => {
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
const cachedSession = {
|
||||
id: 'chat-123',
|
||||
title: 'Old Title',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
// Pre-populate cache
|
||||
(chatService as any).sessionCache.set('chat-123', cachedSession);
|
||||
mockChatStorageService.getChat.mockResolvedValueOnce(mockChat);
|
||||
|
||||
const session = await chatService.getOrCreateSession('chat-123');
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat', // Should be updated from storage
|
||||
messages: [{ role: 'user', content: 'Hello' }], // Should be updated from storage
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.getChat).toHaveBeenCalledWith('chat-123');
|
||||
});
|
||||
|
||||
it('should load session from storage if not cached', async () => {
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValueOnce(mockChat);
|
||||
|
||||
const session = await chatService.getOrCreateSession('chat-123');
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.getChat).toHaveBeenCalledWith('chat-123');
|
||||
});
|
||||
|
||||
it('should create new session if not found', async () => {
|
||||
mockChatStorageService.getChat.mockResolvedValueOnce(null);
|
||||
|
||||
const mockNewChat = {
|
||||
id: 'chat-new',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-new',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.createChat.mockResolvedValueOnce(mockNewChat);
|
||||
|
||||
const session = await chatService.getOrCreateSession('nonexistent');
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-new',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.getChat).toHaveBeenCalledWith('nonexistent');
|
||||
expect(mockChatStorageService.createChat).toHaveBeenCalledWith('New Chat', []);
|
||||
});
|
||||
|
||||
it('should create new session when no sessionId provided', async () => {
|
||||
const mockNewChat = {
|
||||
id: 'chat-new',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-new',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.createChat.mockResolvedValueOnce(mockNewChat);
|
||||
|
||||
const session = await chatService.getOrCreateSession();
|
||||
|
||||
expect(session).toEqual({
|
||||
id: 'chat-new',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.createChat).toHaveBeenCalledWith('New Chat', []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessage', () => {
|
||||
beforeEach(() => {
|
||||
const mockSession = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValue(mockChat);
|
||||
mockChatStorageService.updateChat.mockResolvedValue(mockChat);
|
||||
|
||||
mockChatPipeline.execute.mockResolvedValue({
|
||||
text: 'Hello! How can I help you?',
|
||||
model: 'gpt-3.5-turbo',
|
||||
provider: 'OpenAI',
|
||||
usage: { promptTokens: 10, completionTokens: 8, totalTokens: 18 }
|
||||
});
|
||||
});
|
||||
|
||||
it('should send message and get AI response', async () => {
|
||||
const session = await chatService.sendMessage('chat-123', 'Hello');
|
||||
|
||||
expect(session.messages).toHaveLength(2);
|
||||
expect(session.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: 'Hello'
|
||||
});
|
||||
expect(session.messages[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'Hello! How can I help you?',
|
||||
tool_calls: undefined
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.updateChat).toHaveBeenCalledTimes(2); // Once for user message, once for complete conversation
|
||||
expect(mockChatPipeline.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle streaming callback', async () => {
|
||||
const streamCallback = vi.fn();
|
||||
|
||||
await chatService.sendMessage('chat-123', 'Hello', {}, streamCallback);
|
||||
|
||||
expect(mockChatPipeline.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
streamCallback
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should update title for first message', async () => {
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'New Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValue(mockChat);
|
||||
|
||||
await chatService.sendMessage('chat-123', 'What is the weather like?');
|
||||
|
||||
// Should update title based on first message
|
||||
expect(mockChatStorageService.updateChat).toHaveBeenLastCalledWith(
|
||||
'chat-123',
|
||||
expect.any(Array),
|
||||
'What is the weather like?'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockChatPipeline.execute.mockRejectedValueOnce(new Error('AI service error'));
|
||||
|
||||
const session = await chatService.sendMessage('chat-123', 'Hello');
|
||||
|
||||
expect(session.messages).toHaveLength(2);
|
||||
expect(session.messages[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error processing your request.'
|
||||
});
|
||||
|
||||
expect(session.isStreaming).toBe(false);
|
||||
expect(mockChatStorageService.updateChat).toHaveBeenCalledWith(
|
||||
'chat-123',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error processing your request.'
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tool calls in response', async () => {
|
||||
const toolCalls = [{
|
||||
id: 'call_123',
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'searchNotes',
|
||||
arguments: '{"query": "test"}'
|
||||
}
|
||||
}];
|
||||
|
||||
mockChatPipeline.execute.mockResolvedValueOnce({
|
||||
text: 'I need to search for notes.',
|
||||
model: 'gpt-4',
|
||||
provider: 'OpenAI',
|
||||
tool_calls: toolCalls,
|
||||
usage: { promptTokens: 10, completionTokens: 8, totalTokens: 18 }
|
||||
});
|
||||
|
||||
const session = await chatService.sendMessage('chat-123', 'Search for notes about AI');
|
||||
|
||||
expect(session.messages[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'I need to search for notes.',
|
||||
tool_calls: toolCalls
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendContextAwareMessage', () => {
|
||||
beforeEach(() => {
|
||||
const mockSession = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValue(mockChat);
|
||||
mockChatStorageService.updateChat.mockResolvedValue(mockChat);
|
||||
|
||||
mockChatPipeline.execute.mockResolvedValue({
|
||||
text: 'Based on the context, here is my response.',
|
||||
model: 'gpt-4',
|
||||
provider: 'OpenAI',
|
||||
usage: { promptTokens: 20, completionTokens: 15, totalTokens: 35 }
|
||||
});
|
||||
});
|
||||
|
||||
it('should send context-aware message with note ID', async () => {
|
||||
const session = await chatService.sendContextAwareMessage(
|
||||
'chat-123',
|
||||
'What is this note about?',
|
||||
'note-456'
|
||||
);
|
||||
|
||||
expect(session.messages).toHaveLength(2);
|
||||
expect(session.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: 'What is this note about?'
|
||||
});
|
||||
|
||||
expect(mockChatPipeline.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
noteId: 'note-456',
|
||||
query: 'What is this note about?',
|
||||
showThinking: false
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockChatStorageService.updateChat).toHaveBeenLastCalledWith(
|
||||
'chat-123',
|
||||
expect.any(Array),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
contextNoteId: 'note-456'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use agent pipeline when showThinking is enabled', async () => {
|
||||
await chatService.sendContextAwareMessage(
|
||||
'chat-123',
|
||||
'Analyze this note',
|
||||
'note-456',
|
||||
{ showThinking: true }
|
||||
);
|
||||
|
||||
expect(mockChatPipeline.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
showThinking: true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors in context-aware messages', async () => {
|
||||
mockChatPipeline.execute.mockRejectedValueOnce(new Error('Context error'));
|
||||
|
||||
const session = await chatService.sendContextAwareMessage(
|
||||
'chat-123',
|
||||
'What is this note about?',
|
||||
'note-456'
|
||||
);
|
||||
|
||||
expect(session.messages[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error processing the context.'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addNoteContext', () => {
|
||||
it('should add note context to session', async () => {
|
||||
const mockSession = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Tell me about AI features' }
|
||||
],
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: mockSession.messages,
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValue(mockChat);
|
||||
mockChatStorageService.updateChat.mockResolvedValue(mockChat);
|
||||
|
||||
// Mock the pipeline's context extraction stage
|
||||
mockChatPipeline.stages.contextExtraction.execute.mockResolvedValue({
|
||||
context: 'This note contains information about AI features...',
|
||||
sources: [
|
||||
{
|
||||
noteId: 'note-456',
|
||||
title: 'AI Features',
|
||||
similarity: 0.95,
|
||||
content: 'AI features content'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const session = await chatService.addNoteContext('chat-123', 'note-456');
|
||||
|
||||
expect(session.messages).toHaveLength(2);
|
||||
expect(session.messages[1]).toEqual({
|
||||
role: 'user',
|
||||
content: 'Context: This note contains information about AI features...'
|
||||
});
|
||||
|
||||
expect(mockChatStorageService.recordSources).toHaveBeenCalledWith(
|
||||
'chat-123',
|
||||
[expect.objectContaining({
|
||||
noteId: 'note-456',
|
||||
title: 'AI Features',
|
||||
similarity: 0.95,
|
||||
content: 'AI features content'
|
||||
})]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addSemanticNoteContext', () => {
|
||||
it('should add semantic note context to session', async () => {
|
||||
const mockSession = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
noteId: 'chat-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
mockChatStorageService.getChat.mockResolvedValue(mockChat);
|
||||
mockChatStorageService.updateChat.mockResolvedValue(mockChat);
|
||||
|
||||
mockChatPipeline.stages.semanticContextExtraction.execute.mockResolvedValue({
|
||||
context: 'Semantic context about machine learning...',
|
||||
sources: []
|
||||
});
|
||||
|
||||
const session = await chatService.addSemanticNoteContext(
|
||||
'chat-123',
|
||||
'note-456',
|
||||
'machine learning algorithms'
|
||||
);
|
||||
|
||||
expect(session.messages).toHaveLength(1);
|
||||
expect(session.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: 'Query: machine learning algorithms\nContext: Semantic context about machine learning...'
|
||||
});
|
||||
|
||||
expect(mockChatPipeline.stages.semanticContextExtraction.execute).toHaveBeenCalledWith({
|
||||
noteId: 'note-456',
|
||||
query: 'machine learning algorithms'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllSessions', () => {
|
||||
it('should return all chat sessions', async () => {
|
||||
const mockChats = [
|
||||
{
|
||||
id: 'chat-1',
|
||||
title: 'Chat 1',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
noteId: 'chat-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
},
|
||||
{
|
||||
id: 'chat-2',
|
||||
title: 'Chat 2',
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
noteId: 'chat-2',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
}
|
||||
];
|
||||
|
||||
mockChatStorageService.getAllChats.mockResolvedValue(mockChats);
|
||||
|
||||
const sessions = await chatService.getAllSessions();
|
||||
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0]).toEqual({
|
||||
id: 'chat-1',
|
||||
title: 'Chat 1',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
isStreaming: false
|
||||
});
|
||||
expect(sessions[1]).toEqual({
|
||||
id: 'chat-2',
|
||||
title: 'Chat 2',
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
isStreaming: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should update cached sessions with latest data', async () => {
|
||||
const mockChats = [
|
||||
{
|
||||
id: 'chat-1',
|
||||
title: 'Updated Title',
|
||||
messages: [{ role: 'user', content: 'Updated message' }],
|
||||
noteId: 'chat-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {}
|
||||
}
|
||||
];
|
||||
|
||||
// Pre-populate cache with old data
|
||||
(chatService as any).sessionCache.set('chat-1', {
|
||||
id: 'chat-1',
|
||||
title: 'Old Title',
|
||||
messages: [{ role: 'user', content: 'Old message' }],
|
||||
isStreaming: true
|
||||
});
|
||||
|
||||
mockChatStorageService.getAllChats.mockResolvedValue(mockChats);
|
||||
|
||||
const sessions = await chatService.getAllSessions();
|
||||
|
||||
expect(sessions[0]).toEqual({
|
||||
id: 'chat-1',
|
||||
title: 'Updated Title',
|
||||
messages: [{ role: 'user', content: 'Updated message' }],
|
||||
isStreaming: true // Should preserve streaming state
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSession', () => {
|
||||
it('should delete session from cache and storage', async () => {
|
||||
// Pre-populate cache
|
||||
(chatService as any).sessionCache.set('chat-123', {
|
||||
id: 'chat-123',
|
||||
title: 'Test Chat',
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
mockChatStorageService.deleteChat.mockResolvedValue(true);
|
||||
|
||||
const result = await chatService.deleteSession('chat-123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect((chatService as any).sessionCache.has('chat-123')).toBe(false);
|
||||
expect(mockChatStorageService.deleteChat).toHaveBeenCalledWith('chat-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateChatCompletion', () => {
|
||||
it('should use AI service directly for simple completion', async () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' }
|
||||
];
|
||||
|
||||
const mockService = {
|
||||
getName: () => 'OpenAI',
|
||||
generateChatCompletion: vi.fn().mockResolvedValue({
|
||||
text: 'Hello! How can I help?',
|
||||
model: 'gpt-3.5-turbo',
|
||||
provider: 'OpenAI'
|
||||
})
|
||||
};
|
||||
|
||||
mockAiServiceManager.getService.mockResolvedValue(mockService);
|
||||
|
||||
const result = await chatService.generateChatCompletion(messages);
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Hello! How can I help?',
|
||||
model: 'gpt-3.5-turbo',
|
||||
provider: 'OpenAI'
|
||||
});
|
||||
|
||||
expect(mockService.generateChatCompletion).toHaveBeenCalledWith(messages, {});
|
||||
});
|
||||
|
||||
it('should use pipeline for advanced context', async () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' }
|
||||
];
|
||||
|
||||
const options = {
|
||||
useAdvancedContext: true,
|
||||
noteId: 'note-123'
|
||||
};
|
||||
|
||||
// Mock AI service for this test
|
||||
const mockService = {
|
||||
getName: () => 'OpenAI',
|
||||
generateChatCompletion: vi.fn()
|
||||
};
|
||||
mockAiServiceManager.getService.mockResolvedValue(mockService);
|
||||
|
||||
mockChatPipeline.execute.mockResolvedValue({
|
||||
text: 'Response with context',
|
||||
model: 'gpt-4',
|
||||
provider: 'OpenAI',
|
||||
tool_calls: []
|
||||
});
|
||||
|
||||
const result = await chatService.generateChatCompletion(messages, options);
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Response with context',
|
||||
model: 'gpt-4',
|
||||
provider: 'OpenAI',
|
||||
tool_calls: []
|
||||
});
|
||||
|
||||
expect(mockChatPipeline.execute).toHaveBeenCalledWith({
|
||||
messages,
|
||||
options,
|
||||
query: 'Hello',
|
||||
noteId: 'note-123'
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when no AI service available', async () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' }
|
||||
];
|
||||
|
||||
mockAiServiceManager.getService.mockResolvedValue(null);
|
||||
|
||||
await expect(chatService.generateChatCompletion(messages)).rejects.toThrow(
|
||||
'No AI service available'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipeline metrics', () => {
|
||||
it('should get pipeline metrics', () => {
|
||||
mockChatPipeline.getMetrics.mockReturnValue({ requestCount: 5 });
|
||||
|
||||
const metrics = chatService.getPipelineMetrics();
|
||||
|
||||
expect(metrics).toEqual({ requestCount: 5 });
|
||||
expect(mockChatPipeline.getMetrics).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset pipeline metrics', () => {
|
||||
chatService.resetPipelineMetrics();
|
||||
|
||||
expect(mockChatPipeline.resetMetrics).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle different pipeline types', () => {
|
||||
mockChatPipeline.getMetrics.mockReturnValue({ requestCount: 3 });
|
||||
|
||||
const metrics = chatService.getPipelineMetrics('agent');
|
||||
|
||||
expect(metrics).toEqual({ requestCount: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTitleFromMessages', () => {
|
||||
it('should generate title from first user message', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'What is machine learning?' },
|
||||
{ role: 'assistant', content: 'Machine learning is...' }
|
||||
];
|
||||
|
||||
// Access private method for testing
|
||||
const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService);
|
||||
const title = generateTitle(messages);
|
||||
|
||||
expect(title).toBe('What is machine learning?');
|
||||
});
|
||||
|
||||
it('should truncate long titles', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'This is a very long message that should be truncated because it exceeds the maximum length' },
|
||||
{ role: 'assistant', content: 'Response' }
|
||||
];
|
||||
|
||||
const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService);
|
||||
const title = generateTitle(messages);
|
||||
|
||||
expect(title).toBe('This is a very long message...');
|
||||
expect(title.length).toBe(30);
|
||||
});
|
||||
|
||||
it('should return default title for empty or invalid messages', () => {
|
||||
const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService);
|
||||
|
||||
expect(generateTitle([])).toBe('New Chat');
|
||||
expect(generateTitle([{ role: 'assistant', content: 'Hello' }])).toBe('New Chat');
|
||||
});
|
||||
|
||||
it('should use first line for multiline messages', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'First line\nSecond line\nThird line' },
|
||||
{ role: 'assistant', content: 'Response' }
|
||||
];
|
||||
|
||||
const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService);
|
||||
const title = generateTitle(messages);
|
||||
|
||||
expect(title).toBe('First line');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,595 +0,0 @@
|
||||
import type { Message, ChatCompletionOptions, ChatResponse } from './ai_interface.js';
|
||||
import chatStorageService from './chat_storage_service.js';
|
||||
import log from '../log.js';
|
||||
import { CONTEXT_PROMPTS, ERROR_PROMPTS } from './constants/llm_prompt_constants.js';
|
||||
import { ChatPipeline } from './pipeline/chat_pipeline.js';
|
||||
import type { ChatPipelineConfig, StreamCallback } from './pipeline/interfaces.js';
|
||||
import aiServiceManager from './ai_service_manager.js';
|
||||
import type { ChatPipelineInput } from './pipeline/interfaces.js';
|
||||
import type { NoteSearchResult } from './interfaces/context_interfaces.js';
|
||||
|
||||
// Update the ChatCompletionOptions interface to include the missing properties
|
||||
declare module './ai_interface.js' {
|
||||
interface ChatCompletionOptions {
|
||||
pipeline?: string;
|
||||
noteId?: string;
|
||||
useAdvancedContext?: boolean;
|
||||
showThinking?: boolean;
|
||||
enableTools?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Add a type for context extraction result
|
||||
interface ContextExtractionResult {
|
||||
context: string;
|
||||
sources?: NoteSearchResult[];
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
isStreaming?: boolean;
|
||||
options?: ChatCompletionOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat pipeline configurations for different use cases
|
||||
*/
|
||||
const PIPELINE_CONFIGS: Record<string, Partial<ChatPipelineConfig>> = {
|
||||
default: {
|
||||
enableStreaming: true,
|
||||
enableMetrics: true
|
||||
},
|
||||
agent: {
|
||||
enableStreaming: true,
|
||||
enableMetrics: true,
|
||||
maxToolCallIterations: 5
|
||||
},
|
||||
performance: {
|
||||
enableStreaming: false,
|
||||
enableMetrics: true
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Service for managing chat interactions and history
|
||||
*/
|
||||
export class ChatService {
|
||||
private sessionCache: Map<string, ChatSession> = new Map();
|
||||
private pipelines: Map<string, ChatPipeline> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Initialize pipelines
|
||||
Object.entries(PIPELINE_CONFIGS).forEach(([name, config]) => {
|
||||
this.pipelines.set(name, new ChatPipeline(config));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pipeline by name, or the default one
|
||||
*/
|
||||
private getPipeline(name: string = 'default'): ChatPipeline {
|
||||
return this.pipelines.get(name) || this.pipelines.get('default')!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat session
|
||||
*/
|
||||
async createSession(title?: string, initialMessages: Message[] = []): Promise<ChatSession> {
|
||||
// Create a new Chat Note as the source of truth
|
||||
const chat = await chatStorageService.createChat(title || 'New Chat', initialMessages);
|
||||
|
||||
const session: ChatSession = {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
messages: chat.messages,
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
// Session is just a cache now
|
||||
this.sessionCache.set(chat.id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing session or create a new one
|
||||
*/
|
||||
async getOrCreateSession(sessionId?: string): Promise<ChatSession> {
|
||||
if (sessionId) {
|
||||
// First check the cache
|
||||
const cachedSession = this.sessionCache.get(sessionId);
|
||||
if (cachedSession) {
|
||||
// Refresh the data from the source of truth
|
||||
const chat = await chatStorageService.getChat(sessionId);
|
||||
if (chat) {
|
||||
// Update the cached session with latest data from the note
|
||||
cachedSession.title = chat.title;
|
||||
cachedSession.messages = chat.messages;
|
||||
return cachedSession;
|
||||
}
|
||||
} else {
|
||||
// Not in cache, load from the chat note
|
||||
const chat = await chatStorageService.getChat(sessionId);
|
||||
if (chat) {
|
||||
const session: ChatSession = {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
messages: chat.messages,
|
||||
isStreaming: false
|
||||
};
|
||||
|
||||
this.sessionCache.set(chat.id, session);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.createSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message in a chat session and get the AI response
|
||||
*/
|
||||
async sendMessage(
|
||||
sessionId: string,
|
||||
content: string,
|
||||
options?: ChatCompletionOptions,
|
||||
streamCallback?: StreamCallback
|
||||
): Promise<ChatSession> {
|
||||
const session = await this.getOrCreateSession(sessionId);
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content
|
||||
};
|
||||
|
||||
session.messages.push(userMessage);
|
||||
session.isStreaming = true;
|
||||
|
||||
try {
|
||||
// Immediately save the user message
|
||||
await chatStorageService.updateChat(session.id, session.messages);
|
||||
|
||||
// Log message processing
|
||||
log.info(`Processing message: "${content.substring(0, 100)}..."`);
|
||||
|
||||
// Select pipeline to use
|
||||
const pipeline = this.getPipeline();
|
||||
|
||||
// Include sessionId in the options for tool execution tracking
|
||||
const pipelineOptions = {
|
||||
...(options || session.options || {}),
|
||||
sessionId: session.id
|
||||
};
|
||||
|
||||
// Execute the pipeline
|
||||
const response = await pipeline.execute({
|
||||
messages: session.messages,
|
||||
options: pipelineOptions,
|
||||
query: content,
|
||||
streamCallback
|
||||
});
|
||||
|
||||
// Add assistant message
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: response.text,
|
||||
tool_calls: response.tool_calls
|
||||
};
|
||||
|
||||
session.messages.push(assistantMessage);
|
||||
session.isStreaming = false;
|
||||
|
||||
// Save metadata about the response
|
||||
const metadata = {
|
||||
model: response.model,
|
||||
provider: response.provider,
|
||||
usage: response.usage
|
||||
};
|
||||
|
||||
// If there are tool calls, make sure they're stored in metadata
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
// Let the storage service extract and save tool executions
|
||||
// The tool results are already in the messages
|
||||
}
|
||||
|
||||
// Save the complete conversation with metadata
|
||||
await chatStorageService.updateChat(session.id, session.messages, undefined, metadata);
|
||||
|
||||
// If first message, update the title based on content
|
||||
if (session.messages.length <= 2 && (!session.title || session.title === 'New Chat')) {
|
||||
const title = this.generateTitleFromMessages(session.messages);
|
||||
session.title = title;
|
||||
await chatStorageService.updateChat(session.id, session.messages, title);
|
||||
}
|
||||
|
||||
return session;
|
||||
|
||||
} catch (error: unknown) {
|
||||
session.isStreaming = false;
|
||||
console.error('Error in AI chat:', this.handleError(error));
|
||||
|
||||
// Add error message
|
||||
const errorMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: ERROR_PROMPTS.USER_ERRORS.GENERAL_ERROR
|
||||
};
|
||||
|
||||
session.messages.push(errorMessage);
|
||||
|
||||
// Save the conversation with error
|
||||
await chatStorageService.updateChat(session.id, session.messages);
|
||||
|
||||
// Notify streaming error if callback provided
|
||||
if (streamCallback) {
|
||||
streamCallback(errorMessage.content, true);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message with context from a specific note
|
||||
*/
|
||||
async sendContextAwareMessage(
|
||||
sessionId: string,
|
||||
content: string,
|
||||
noteId: string,
|
||||
options?: ChatCompletionOptions,
|
||||
streamCallback?: StreamCallback
|
||||
): Promise<ChatSession> {
|
||||
const session = await this.getOrCreateSession(sessionId);
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content
|
||||
};
|
||||
|
||||
session.messages.push(userMessage);
|
||||
session.isStreaming = true;
|
||||
|
||||
try {
|
||||
// Immediately save the user message
|
||||
await chatStorageService.updateChat(session.id, session.messages);
|
||||
|
||||
// Log message processing
|
||||
log.info(`Processing context-aware message: "${content.substring(0, 100)}..."`);
|
||||
log.info(`Using context from note: ${noteId}`);
|
||||
|
||||
// Get showThinking option if it exists
|
||||
const showThinking = options?.showThinking === true;
|
||||
|
||||
// Select appropriate pipeline based on whether agent tools are needed
|
||||
const pipelineType = showThinking ? 'agent' : 'default';
|
||||
const pipeline = this.getPipeline(pipelineType);
|
||||
|
||||
// Include sessionId in the options for tool execution tracking
|
||||
const pipelineOptions = {
|
||||
...(options || session.options || {}),
|
||||
sessionId: session.id
|
||||
};
|
||||
|
||||
// Execute the pipeline with note context
|
||||
const response = await pipeline.execute({
|
||||
messages: session.messages,
|
||||
options: pipelineOptions,
|
||||
noteId,
|
||||
query: content,
|
||||
showThinking,
|
||||
streamCallback
|
||||
});
|
||||
|
||||
// Add assistant message
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: response.text,
|
||||
tool_calls: response.tool_calls
|
||||
};
|
||||
|
||||
session.messages.push(assistantMessage);
|
||||
session.isStreaming = false;
|
||||
|
||||
// Save metadata about the response
|
||||
const metadata = {
|
||||
model: response.model,
|
||||
provider: response.provider,
|
||||
usage: response.usage,
|
||||
contextNoteId: noteId // Store the note ID used for context
|
||||
};
|
||||
|
||||
// If there are tool calls, make sure they're stored in metadata
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
// Let the storage service extract and save tool executions
|
||||
// The tool results are already in the messages
|
||||
}
|
||||
|
||||
// Save the complete conversation with metadata to the Chat Note (the single source of truth)
|
||||
await chatStorageService.updateChat(session.id, session.messages, undefined, metadata);
|
||||
|
||||
// If first message, update the title
|
||||
if (session.messages.length <= 2 && (!session.title || session.title === 'New Chat')) {
|
||||
const title = this.generateTitleFromMessages(session.messages);
|
||||
session.title = title;
|
||||
await chatStorageService.updateChat(session.id, session.messages, title);
|
||||
}
|
||||
|
||||
return session;
|
||||
|
||||
} catch (error: unknown) {
|
||||
session.isStreaming = false;
|
||||
console.error('Error in context-aware chat:', this.handleError(error));
|
||||
|
||||
// Add error message
|
||||
const errorMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: ERROR_PROMPTS.USER_ERRORS.CONTEXT_ERROR
|
||||
};
|
||||
|
||||
session.messages.push(errorMessage);
|
||||
|
||||
// Save the conversation with error to the Chat Note
|
||||
await chatStorageService.updateChat(session.id, session.messages);
|
||||
|
||||
// Notify streaming error if callback provided
|
||||
if (streamCallback) {
|
||||
streamCallback(errorMessage.content, true);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add context from the current note to the chat
|
||||
*
|
||||
* @param sessionId - The ID of the chat session
|
||||
* @param noteId - The ID of the note to add context from
|
||||
* @param useSmartContext - Whether to use smart context extraction (default: true)
|
||||
* @returns The updated chat session
|
||||
*/
|
||||
async addNoteContext(sessionId: string, noteId: string, useSmartContext = true): Promise<ChatSession> {
|
||||
const session = await this.getOrCreateSession(sessionId);
|
||||
|
||||
// Get the last user message to use as context for semantic search
|
||||
const lastUserMessage = [...session.messages].reverse()
|
||||
.find(msg => msg.role === 'user' && msg.content.length > 10)?.content || '';
|
||||
|
||||
// Use the context extraction stage from the pipeline
|
||||
const pipeline = this.getPipeline();
|
||||
const contextResult = await pipeline.stages.contextExtraction.execute({
|
||||
noteId,
|
||||
query: lastUserMessage,
|
||||
useSmartContext
|
||||
}) as ContextExtractionResult;
|
||||
|
||||
const contextMessage: Message = {
|
||||
role: 'user',
|
||||
content: CONTEXT_PROMPTS.NOTE_CONTEXT_PROMPT.replace('{context}', contextResult.context)
|
||||
};
|
||||
|
||||
session.messages.push(contextMessage);
|
||||
|
||||
// Store the context note id in metadata
|
||||
const metadata = {
|
||||
contextNoteId: noteId
|
||||
};
|
||||
|
||||
// Check if the context extraction result has sources
|
||||
if (contextResult.sources && contextResult.sources.length > 0) {
|
||||
// Convert the sources to match expected format (handling null vs undefined)
|
||||
const sources = contextResult.sources.map(source => ({
|
||||
noteId: source.noteId,
|
||||
title: source.title,
|
||||
similarity: source.similarity,
|
||||
// Replace null with undefined for content
|
||||
content: source.content === null ? undefined : source.content
|
||||
}));
|
||||
|
||||
// Store these sources in metadata
|
||||
await chatStorageService.recordSources(session.id, sources);
|
||||
}
|
||||
|
||||
await chatStorageService.updateChat(session.id, session.messages, undefined, metadata);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add semantically relevant context from a note based on a specific query
|
||||
*/
|
||||
async addSemanticNoteContext(sessionId: string, noteId: string, query: string): Promise<ChatSession> {
|
||||
const session = await this.getOrCreateSession(sessionId);
|
||||
|
||||
// Use the semantic context extraction stage from the pipeline
|
||||
const pipeline = this.getPipeline();
|
||||
const contextResult = await pipeline.stages.semanticContextExtraction.execute({
|
||||
noteId,
|
||||
query
|
||||
});
|
||||
|
||||
const contextMessage: Message = {
|
||||
role: 'user',
|
||||
content: CONTEXT_PROMPTS.SEMANTIC_NOTE_CONTEXT_PROMPT
|
||||
.replace('{query}', query)
|
||||
.replace('{context}', contextResult.context)
|
||||
};
|
||||
|
||||
session.messages.push(contextMessage);
|
||||
|
||||
// Store the context note id and query in metadata
|
||||
const metadata = {
|
||||
contextNoteId: noteId
|
||||
};
|
||||
|
||||
// Check if the semantic context extraction result has sources
|
||||
const contextSources = (contextResult as ContextExtractionResult).sources || [];
|
||||
if (contextSources && contextSources.length > 0) {
|
||||
// Convert the sources to the format expected by recordSources
|
||||
const sources = contextSources.map((source) => ({
|
||||
noteId: source.noteId,
|
||||
title: source.title,
|
||||
similarity: source.similarity,
|
||||
content: source.content === null ? undefined : source.content
|
||||
}));
|
||||
|
||||
// Store these sources in metadata
|
||||
await chatStorageService.recordSources(session.id, sources);
|
||||
}
|
||||
|
||||
await chatStorageService.updateChat(session.id, session.messages, undefined, metadata);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user's chat sessions
|
||||
*/
|
||||
async getAllSessions(): Promise<ChatSession[]> {
|
||||
// Always fetch the latest data from notes
|
||||
const chats = await chatStorageService.getAllChats();
|
||||
|
||||
// Update the cache with the latest data
|
||||
return chats.map(chat => {
|
||||
const cachedSession = this.sessionCache.get(chat.id);
|
||||
|
||||
const session: ChatSession = {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
messages: chat.messages,
|
||||
isStreaming: cachedSession?.isStreaming || false
|
||||
};
|
||||
|
||||
// Update the cache
|
||||
if (cachedSession) {
|
||||
cachedSession.title = chat.title;
|
||||
cachedSession.messages = chat.messages;
|
||||
} else {
|
||||
this.sessionCache.set(chat.id, session);
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a chat session
|
||||
*/
|
||||
async deleteSession(sessionId: string): Promise<boolean> {
|
||||
this.sessionCache.delete(sessionId);
|
||||
return chatStorageService.deleteChat(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pipeline performance metrics
|
||||
*/
|
||||
getPipelineMetrics(pipelineType: string = 'default'): unknown {
|
||||
const pipeline = this.getPipeline(pipelineType);
|
||||
return pipeline.getMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset pipeline metrics
|
||||
*/
|
||||
resetPipelineMetrics(pipelineType: string = 'default'): void {
|
||||
const pipeline = this.getPipeline(pipelineType);
|
||||
pipeline.resetMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a title from the first messages in a conversation
|
||||
*/
|
||||
private generateTitleFromMessages(messages: Message[]): string {
|
||||
if (messages.length < 2) {
|
||||
return 'New Chat';
|
||||
}
|
||||
|
||||
// Get the first user message
|
||||
const firstUserMessage = messages.find(m => m.role === 'user');
|
||||
if (!firstUserMessage) {
|
||||
return 'New Chat';
|
||||
}
|
||||
|
||||
// Extract first line or first few words
|
||||
const firstLine = firstUserMessage.content.split('\n')[0].trim();
|
||||
|
||||
if (firstLine.length <= 30) {
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
// Take first 30 chars if too long
|
||||
return firstLine.substring(0, 27) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a chat completion with a sequence of messages
|
||||
* @param messages Messages array to send to the AI provider
|
||||
* @param options Chat completion options
|
||||
*/
|
||||
async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise<ChatResponse> {
|
||||
log.info(`========== CHAT SERVICE FLOW CHECK ==========`);
|
||||
log.info(`Entered generateChatCompletion in ChatService`);
|
||||
log.info(`Using pipeline for chat completion: ${this.getPipeline(options.pipeline).constructor.name}`);
|
||||
log.info(`Tool support enabled: ${options.enableTools !== false}`);
|
||||
|
||||
try {
|
||||
// Get AI service
|
||||
const service = await aiServiceManager.getService();
|
||||
if (!service) {
|
||||
throw new Error('No AI service available');
|
||||
}
|
||||
|
||||
log.info(`Using AI service: ${service.getName()}`);
|
||||
|
||||
// Prepare query extraction
|
||||
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
|
||||
const query = lastUserMessage ? lastUserMessage.content : undefined;
|
||||
|
||||
// For advanced context processing, use the pipeline
|
||||
if (options.useAdvancedContext && query) {
|
||||
log.info(`Using chat pipeline for advanced context with query: ${query.substring(0, 50)}...`);
|
||||
|
||||
// Create a pipeline input with the query and messages
|
||||
const pipelineInput: ChatPipelineInput = {
|
||||
messages,
|
||||
options,
|
||||
query,
|
||||
noteId: options.noteId
|
||||
};
|
||||
|
||||
// Execute the pipeline
|
||||
const pipeline = this.getPipeline(options.pipeline);
|
||||
const response = await pipeline.execute(pipelineInput);
|
||||
log.info(`Pipeline execution complete, response contains tools: ${response.tool_calls ? 'yes' : 'no'}`);
|
||||
if (response.tool_calls) {
|
||||
log.info(`Tool calls in pipeline response: ${response.tool_calls.length}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// If not using advanced context, use direct service call
|
||||
return await service.generateChatCompletion(messages, options);
|
||||
} catch (error: unknown) {
|
||||
console.error('Error in generateChatCompletion:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error handler utility
|
||||
*/
|
||||
private handleError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message || String(error);
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const chatService = new ChatService();
|
||||
export default chatService;
|
||||
161
apps/server/src/services/llm/cleanup_obsolete_files.sh
Executable file
161
apps/server/src/services/llm/cleanup_obsolete_files.sh
Executable file
@ -0,0 +1,161 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Cleanup script for obsolete LLM files after Phase 1 and Phase 2 refactoring
|
||||
# This script removes files that have been replaced by the simplified architecture
|
||||
|
||||
echo "======================================"
|
||||
echo "LLM Cleanup Script - Phase 1 & 2"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
echo "This script will remove obsolete files replaced by:"
|
||||
echo "- Simplified 4-stage pipeline"
|
||||
echo "- Centralized configuration service"
|
||||
echo "- New tool format adapter"
|
||||
echo ""
|
||||
|
||||
# Safety check
|
||||
read -p "Are you sure you want to remove obsolete LLM files? (y/N): " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Cleanup cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Counter for removed files
|
||||
removed_count=0
|
||||
|
||||
# Function to safely remove a file
|
||||
remove_file() {
|
||||
local file=$1
|
||||
if [ -f "$file" ]; then
|
||||
echo "Removing: $file"
|
||||
rm "$file"
|
||||
((removed_count++))
|
||||
else
|
||||
echo "Already removed or doesn't exist: $file"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "Starting cleanup..."
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# PIPELINE STAGES - Replaced by simplified_pipeline.ts
|
||||
# ============================================
|
||||
echo "Removing old pipeline stages (replaced by 4-stage simplified pipeline)..."
|
||||
|
||||
# Old 9-stage pipeline implementation
|
||||
remove_file "pipeline/stages/agent_tools_context_stage.ts"
|
||||
remove_file "pipeline/stages/context_extraction_stage.ts"
|
||||
remove_file "pipeline/stages/error_recovery_stage.ts"
|
||||
remove_file "pipeline/stages/llm_completion_stage.ts"
|
||||
remove_file "pipeline/stages/message_preparation_stage.ts"
|
||||
remove_file "pipeline/stages/model_selection_stage.ts"
|
||||
remove_file "pipeline/stages/response_processing_stage.ts"
|
||||
remove_file "pipeline/stages/semantic_context_extraction_stage.ts"
|
||||
remove_file "pipeline/stages/tool_calling_stage.ts"
|
||||
remove_file "pipeline/stages/user_interaction_stage.ts"
|
||||
|
||||
# Old pipeline base class
|
||||
remove_file "pipeline/pipeline_stage.ts"
|
||||
|
||||
# Old complex pipeline (replaced by simplified_pipeline.ts)
|
||||
remove_file "pipeline/chat_pipeline.ts"
|
||||
remove_file "pipeline/chat_pipeline.spec.ts"
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# CONFIGURATION - Replaced by configuration_service.ts
|
||||
# ============================================
|
||||
echo "Removing old configuration files (replaced by centralized configuration_service.ts)..."
|
||||
|
||||
# Old configuration helpers are still used, but configuration_manager can be removed if it exists
|
||||
remove_file "config/configuration_manager.ts"
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# FORMATTERS - Consolidated into tool_format_adapter.ts
|
||||
# ============================================
|
||||
echo "Removing old formatter files (replaced by tool_format_adapter.ts)..."
|
||||
|
||||
# Old individual formatters if they exist
|
||||
remove_file "formatters/base_formatter.ts"
|
||||
remove_file "formatters/openai_formatter.ts"
|
||||
remove_file "formatters/anthropic_formatter.ts"
|
||||
remove_file "formatters/ollama_formatter.ts"
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# DUPLICATE SERVICES - Consolidated
|
||||
# ============================================
|
||||
echo "Removing duplicate service files..."
|
||||
|
||||
# ChatService is replaced by RestChatService with simplified pipeline
|
||||
remove_file "chat_service.ts"
|
||||
remove_file "chat_service.spec.ts"
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# OLD INTERFACES - Check which are still needed
|
||||
# ============================================
|
||||
echo "Checking interfaces..."
|
||||
|
||||
# Note: Some interfaces may still be needed, so we'll be careful here
|
||||
# The pipeline/interfaces.ts is still used by pipeline_adapter.ts
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# UNUSED CONTEXT EXTRACTORS
|
||||
# ============================================
|
||||
echo "Checking context extractors..."
|
||||
|
||||
# These might still be used, so let's check first
|
||||
echo "Note: Context extractors in context_extractors/ may still be in use"
|
||||
echo "Skipping context_extractors for safety"
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# REMOVE EMPTY DIRECTORIES
|
||||
# ============================================
|
||||
echo "Removing empty directories..."
|
||||
|
||||
# Remove stages directory if empty
|
||||
if [ -d "pipeline/stages" ]; then
|
||||
if [ -z "$(ls -A pipeline/stages)" ]; then
|
||||
echo "Removing empty directory: pipeline/stages"
|
||||
rmdir "pipeline/stages"
|
||||
((removed_count++))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Remove formatters directory if empty
|
||||
if [ -d "formatters" ]; then
|
||||
if [ -z "$(ls -A formatters)" ]; then
|
||||
echo "Removing empty directory: formatters"
|
||||
rmdir "formatters"
|
||||
((removed_count++))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "Cleanup Complete!"
|
||||
echo "======================================"
|
||||
echo "Removed $removed_count files/directories"
|
||||
echo ""
|
||||
echo "Remaining structure:"
|
||||
echo "- simplified_pipeline.ts (new 4-stage pipeline)"
|
||||
echo "- pipeline_adapter.ts (backward compatibility)"
|
||||
echo "- configuration_service.ts (centralized config)"
|
||||
echo "- model_registry.ts (model capabilities)"
|
||||
echo "- logging_service.ts (structured logging)"
|
||||
echo "- tool_format_adapter.ts (unified tool conversion)"
|
||||
echo ""
|
||||
echo "Note: The pipeline_adapter.ts provides backward compatibility"
|
||||
echo "until all references to the old pipeline are updated."
|
||||
@ -1,309 +0,0 @@
|
||||
import options from '../../options.js';
|
||||
import log from '../../log.js';
|
||||
import type {
|
||||
AIConfig,
|
||||
ProviderPrecedenceConfig,
|
||||
ModelIdentifier,
|
||||
ModelConfig,
|
||||
ProviderType,
|
||||
ConfigValidationResult,
|
||||
ProviderSettings,
|
||||
OpenAISettings,
|
||||
AnthropicSettings,
|
||||
OllamaSettings
|
||||
} from '../interfaces/configuration_interfaces.js';
|
||||
|
||||
/**
|
||||
* Configuration manager that handles conversion from string-based options
|
||||
* to proper typed configuration objects.
|
||||
*
|
||||
* This is the ONLY place where string parsing should happen for LLM configurations.
|
||||
*/
|
||||
export class ConfigurationManager {
|
||||
private static instance: ConfigurationManager | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): ConfigurationManager {
|
||||
if (!ConfigurationManager.instance) {
|
||||
ConfigurationManager.instance = new ConfigurationManager();
|
||||
}
|
||||
return ConfigurationManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the complete AI configuration - always fresh, no caching
|
||||
*/
|
||||
public async getAIConfig(): Promise<AIConfig> {
|
||||
try {
|
||||
const config: AIConfig = {
|
||||
enabled: await this.getAIEnabled(),
|
||||
selectedProvider: await this.getSelectedProvider(),
|
||||
defaultModels: await this.getDefaultModels(),
|
||||
providerSettings: await this.getProviderSettings()
|
||||
};
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
log.error(`Error loading AI configuration: ${error}`);
|
||||
return this.getDefaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected AI provider
|
||||
*/
|
||||
public async getSelectedProvider(): Promise<ProviderType | null> {
|
||||
try {
|
||||
const selectedProvider = options.getOption('aiSelectedProvider');
|
||||
return selectedProvider as ProviderType || null;
|
||||
} catch (error) {
|
||||
log.error(`Error getting selected provider: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse model identifier with optional provider prefix
|
||||
* Handles formats like "gpt-4", "openai:gpt-4", "ollama:llama2:7b"
|
||||
*/
|
||||
public parseModelIdentifier(modelString: string): ModelIdentifier {
|
||||
if (!modelString) {
|
||||
return {
|
||||
modelId: '',
|
||||
fullIdentifier: ''
|
||||
};
|
||||
}
|
||||
|
||||
const parts = modelString.split(':');
|
||||
|
||||
if (parts.length === 1) {
|
||||
// No provider prefix, just model name
|
||||
return {
|
||||
modelId: modelString,
|
||||
fullIdentifier: modelString
|
||||
};
|
||||
}
|
||||
|
||||
// Check if first part is a known provider
|
||||
const potentialProvider = parts[0].toLowerCase();
|
||||
const knownProviders: ProviderType[] = ['openai', 'anthropic', 'ollama'];
|
||||
|
||||
if (knownProviders.includes(potentialProvider as ProviderType)) {
|
||||
// Provider prefix format
|
||||
const provider = potentialProvider as ProviderType;
|
||||
const modelId = parts.slice(1).join(':'); // Rejoin in case model has colons
|
||||
|
||||
return {
|
||||
provider,
|
||||
modelId,
|
||||
fullIdentifier: modelString
|
||||
};
|
||||
}
|
||||
|
||||
// Not a provider prefix, treat whole string as model name
|
||||
return {
|
||||
modelId: modelString,
|
||||
fullIdentifier: modelString
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create model configuration from string
|
||||
*/
|
||||
public createModelConfig(modelString: string, defaultProvider?: ProviderType): ModelConfig {
|
||||
const identifier = this.parseModelIdentifier(modelString);
|
||||
const provider = identifier.provider || defaultProvider || 'openai';
|
||||
|
||||
return {
|
||||
provider,
|
||||
modelId: identifier.modelId,
|
||||
displayName: identifier.fullIdentifier
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default models for each provider - ONLY from user configuration
|
||||
*/
|
||||
public async getDefaultModels(): Promise<Record<ProviderType, string | undefined>> {
|
||||
try {
|
||||
const openaiModel = options.getOption('openaiDefaultModel');
|
||||
const anthropicModel = options.getOption('anthropicDefaultModel');
|
||||
const ollamaModel = options.getOption('ollamaDefaultModel');
|
||||
|
||||
return {
|
||||
openai: openaiModel || undefined,
|
||||
anthropic: anthropicModel || undefined,
|
||||
ollama: ollamaModel || undefined
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`Error loading default models: ${error}`);
|
||||
// Return undefined for all providers if we can't load config
|
||||
return {
|
||||
openai: undefined,
|
||||
anthropic: undefined,
|
||||
ollama: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider-specific settings
|
||||
*/
|
||||
public async getProviderSettings(): Promise<ProviderSettings> {
|
||||
try {
|
||||
const openaiApiKey = options.getOption('openaiApiKey');
|
||||
const openaiBaseUrl = options.getOption('openaiBaseUrl');
|
||||
const openaiDefaultModel = options.getOption('openaiDefaultModel');
|
||||
const anthropicApiKey = options.getOption('anthropicApiKey');
|
||||
const anthropicBaseUrl = options.getOption('anthropicBaseUrl');
|
||||
const anthropicDefaultModel = options.getOption('anthropicDefaultModel');
|
||||
const ollamaBaseUrl = options.getOption('ollamaBaseUrl');
|
||||
const ollamaDefaultModel = options.getOption('ollamaDefaultModel');
|
||||
|
||||
const settings: ProviderSettings = {};
|
||||
|
||||
if (openaiApiKey || openaiBaseUrl || openaiDefaultModel) {
|
||||
settings.openai = {
|
||||
apiKey: openaiApiKey,
|
||||
baseUrl: openaiBaseUrl,
|
||||
defaultModel: openaiDefaultModel
|
||||
};
|
||||
}
|
||||
|
||||
if (anthropicApiKey || anthropicBaseUrl || anthropicDefaultModel) {
|
||||
settings.anthropic = {
|
||||
apiKey: anthropicApiKey,
|
||||
baseUrl: anthropicBaseUrl,
|
||||
defaultModel: anthropicDefaultModel
|
||||
};
|
||||
}
|
||||
|
||||
if (ollamaBaseUrl || ollamaDefaultModel) {
|
||||
settings.ollama = {
|
||||
baseUrl: ollamaBaseUrl,
|
||||
defaultModel: ollamaDefaultModel
|
||||
};
|
||||
}
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
log.error(`Error loading provider settings: ${error}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration
|
||||
*/
|
||||
public async validateConfig(): Promise<ConfigValidationResult> {
|
||||
const result: ConfigValidationResult = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
try {
|
||||
const config = await this.getAIConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
result.warnings.push('AI features are disabled');
|
||||
return result;
|
||||
}
|
||||
|
||||
// Validate selected provider
|
||||
if (!config.selectedProvider) {
|
||||
result.errors.push('No AI provider selected');
|
||||
result.isValid = false;
|
||||
} else {
|
||||
// Validate selected provider settings
|
||||
const providerConfig = config.providerSettings[config.selectedProvider];
|
||||
|
||||
if (config.selectedProvider === 'openai') {
|
||||
const openaiConfig = providerConfig as OpenAISettings | undefined;
|
||||
if (!openaiConfig?.apiKey) {
|
||||
result.warnings.push('OpenAI API key is not configured');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.selectedProvider === 'anthropic') {
|
||||
const anthropicConfig = providerConfig as AnthropicSettings | undefined;
|
||||
if (!anthropicConfig?.apiKey) {
|
||||
result.warnings.push('Anthropic API key is not configured');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.selectedProvider === 'ollama') {
|
||||
const ollamaConfig = providerConfig as OllamaSettings | undefined;
|
||||
if (!ollamaConfig?.baseUrl) {
|
||||
result.warnings.push('Ollama base URL is not configured');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push(`Configuration validation error: ${error}`);
|
||||
result.isValid = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private async getAIEnabled(): Promise<boolean> {
|
||||
try {
|
||||
return options.getOptionBool('aiEnabled');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private parseProviderList(precedenceOption: string | null): string[] {
|
||||
if (!precedenceOption) {
|
||||
// Don't assume any defaults - return empty array
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle JSON array format
|
||||
if (precedenceOption.startsWith('[') && precedenceOption.endsWith(']')) {
|
||||
const parsed = JSON.parse(precedenceOption);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map(p => String(p).trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Handle comma-separated format
|
||||
if (precedenceOption.includes(',')) {
|
||||
return precedenceOption.split(',').map(p => p.trim());
|
||||
}
|
||||
|
||||
// Handle single provider
|
||||
return [precedenceOption.trim()];
|
||||
|
||||
} catch (error) {
|
||||
log.error(`Error parsing provider list "${precedenceOption}": ${error}`);
|
||||
// Don't assume defaults on parse error
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultConfig(): AIConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
selectedProvider: null,
|
||||
defaultModels: {
|
||||
openai: undefined,
|
||||
anthropic: undefined,
|
||||
ollama: undefined
|
||||
},
|
||||
providerSettings: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export default ConfigurationManager.getInstance();
|
||||
@ -1,131 +0,0 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import type { Message } from '../ai_interface.js';
|
||||
import type { MessageFormatter } from '../interfaces/message_formatter.js';
|
||||
import { DEFAULT_SYSTEM_PROMPT, PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js';
|
||||
import {
|
||||
HTML_ALLOWED_TAGS,
|
||||
HTML_ALLOWED_ATTRIBUTES,
|
||||
HTML_TRANSFORMS,
|
||||
HTML_TO_MARKDOWN_PATTERNS,
|
||||
HTML_ENTITY_REPLACEMENTS,
|
||||
ENCODING_FIXES,
|
||||
FORMATTER_LOGS
|
||||
} from '../constants/formatter_constants.js';
|
||||
|
||||
/**
|
||||
* Base formatter with common functionality for all providers
|
||||
* Provider-specific formatters should extend this class
|
||||
*/
|
||||
export abstract class BaseMessageFormatter implements MessageFormatter {
|
||||
/**
|
||||
* Format messages for the LLM API
|
||||
* Each provider should override this method with its specific formatting logic
|
||||
*/
|
||||
abstract formatMessages(messages: Message[], systemPrompt?: string, context?: string): Message[];
|
||||
|
||||
/**
|
||||
* Get the maximum recommended context length for this provider
|
||||
* Each provider should override this with appropriate value
|
||||
*/
|
||||
abstract getMaxContextLength(): number;
|
||||
|
||||
/**
|
||||
* Get the default system prompt
|
||||
* Uses the default prompt from constants
|
||||
*/
|
||||
protected getDefaultSystemPrompt(systemPrompt?: string): string {
|
||||
return systemPrompt || DEFAULT_SYSTEM_PROMPT || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean context content - common method with standard HTML cleaning
|
||||
* Provider-specific formatters can override for custom behavior
|
||||
*/
|
||||
cleanContextContent(content: string): string {
|
||||
if (!content) return '';
|
||||
|
||||
try {
|
||||
// First fix any encoding issues
|
||||
const fixedContent = this.fixEncodingIssues(content);
|
||||
|
||||
// Convert HTML to markdown for better readability
|
||||
const cleaned = sanitizeHtml(fixedContent, {
|
||||
allowedTags: HTML_ALLOWED_TAGS.STANDARD,
|
||||
allowedAttributes: HTML_ALLOWED_ATTRIBUTES.STANDARD,
|
||||
transformTags: HTML_TRANSFORMS.STANDARD
|
||||
});
|
||||
|
||||
// Process inline elements to markdown
|
||||
let markdown = cleaned;
|
||||
|
||||
// Apply all HTML to Markdown patterns
|
||||
const patterns = HTML_TO_MARKDOWN_PATTERNS;
|
||||
for (const pattern of Object.values(patterns)) {
|
||||
markdown = markdown.replace(pattern.pattern, pattern.replacement);
|
||||
}
|
||||
|
||||
// Process list items
|
||||
markdown = this.processListItems(markdown);
|
||||
|
||||
// Fix common HTML entities
|
||||
const entityPatterns = HTML_ENTITY_REPLACEMENTS;
|
||||
for (const pattern of Object.values(entityPatterns)) {
|
||||
markdown = markdown.replace(pattern.pattern, pattern.replacement);
|
||||
}
|
||||
|
||||
return markdown.trim();
|
||||
} catch (error) {
|
||||
console.error(FORMATTER_LOGS.ERROR.CONTEXT_CLEANING("Base"), error);
|
||||
return content; // Return original if cleaning fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process HTML list items in markdown conversion
|
||||
* This is a helper method that safely processes HTML list items
|
||||
*/
|
||||
protected processListItems(content: string): string {
|
||||
// Process unordered lists
|
||||
let result = content.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (match: string, listContent: string) => {
|
||||
return listContent.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n');
|
||||
});
|
||||
|
||||
// Process ordered lists
|
||||
result = result.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (match: string, listContent: string) => {
|
||||
let index = 1;
|
||||
return listContent.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (itemMatch: string, item: string) => {
|
||||
return `${index++}. ${item}\n`;
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix common encoding issues in content
|
||||
* This fixes issues like broken quote characters and other encoding problems
|
||||
*
|
||||
* @param content The content to fix encoding issues in
|
||||
* @returns Content with encoding issues fixed
|
||||
*/
|
||||
protected fixEncodingIssues(content: string): string {
|
||||
if (!content) return '';
|
||||
|
||||
try {
|
||||
// Fix common encoding issues
|
||||
let fixed = content.replace(ENCODING_FIXES.BROKEN_QUOTES.pattern, ENCODING_FIXES.BROKEN_QUOTES.replacement);
|
||||
|
||||
// Fix other common broken unicode
|
||||
fixed = fixed.replace(/[\u{0080}-\u{FFFF}]/gu, (match) => {
|
||||
// Use replacements from constants
|
||||
const replacements = ENCODING_FIXES.UNICODE_REPLACEMENTS;
|
||||
return replacements[match as keyof typeof replacements] || match;
|
||||
});
|
||||
|
||||
return fixed;
|
||||
} catch (error) {
|
||||
console.error(FORMATTER_LOGS.ERROR.ENCODING, error);
|
||||
return content; // Return original if fixing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,232 +0,0 @@
|
||||
import type { Message } from '../ai_interface.js';
|
||||
import { BaseMessageFormatter } from './base_formatter.js';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js';
|
||||
import { LLM_CONSTANTS } from '../constants/provider_constants.js';
|
||||
import {
|
||||
HTML_ALLOWED_TAGS,
|
||||
HTML_ALLOWED_ATTRIBUTES,
|
||||
OLLAMA_CLEANING,
|
||||
FORMATTER_LOGS
|
||||
} from '../constants/formatter_constants.js';
|
||||
import log from '../../log.js';
|
||||
|
||||
/**
|
||||
* Ollama-specific message formatter
|
||||
* Handles the unique requirements of the Ollama API
|
||||
*/
|
||||
export class OllamaMessageFormatter extends BaseMessageFormatter {
|
||||
/**
|
||||
* Maximum recommended context length for Ollama
|
||||
* Smaller than other providers due to Ollama's handling of context
|
||||
*/
|
||||
private static MAX_CONTEXT_LENGTH = LLM_CONSTANTS.CONTEXT_WINDOW.OLLAMA;
|
||||
|
||||
/**
|
||||
* Format messages for the Ollama API
|
||||
* @param messages Messages to format
|
||||
* @param systemPrompt Optional system prompt to use
|
||||
* @param context Optional context to include
|
||||
* @param preserveSystemPrompt When true, preserves existing system messages rather than replacing them
|
||||
*/
|
||||
formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean, useTools?: boolean): Message[] {
|
||||
const formattedMessages: Message[] = [];
|
||||
|
||||
// Log the input messages with all their properties
|
||||
log.info(`Ollama formatter received ${messages.length} messages`);
|
||||
messages.forEach((msg, index) => {
|
||||
const msgKeys = Object.keys(msg);
|
||||
log.info(`Message ${index} - role: ${msg.role}, keys: ${msgKeys.join(', ')}, content length: ${msg.content.length}`);
|
||||
|
||||
// Log special properties if present
|
||||
if (msg.tool_calls) {
|
||||
log.info(`Message ${index} has ${msg.tool_calls.length} tool_calls`);
|
||||
}
|
||||
if (msg.tool_call_id) {
|
||||
log.info(`Message ${index} has tool_call_id: ${msg.tool_call_id}`);
|
||||
}
|
||||
if (msg.name) {
|
||||
log.info(`Message ${index} has name: ${msg.name}`);
|
||||
}
|
||||
});
|
||||
|
||||
// First identify user, system, and tool messages
|
||||
const systemMessages = messages.filter(msg => msg.role === 'system');
|
||||
const nonSystemMessages = messages.filter(msg => msg.role !== 'system');
|
||||
|
||||
// Determine if we should preserve the existing system message
|
||||
if (preserveSystemPrompt && systemMessages.length > 0) {
|
||||
// Preserve the existing system message
|
||||
formattedMessages.push(systemMessages[0]);
|
||||
log.info(`Preserving existing system message: ${systemMessages[0].content.substring(0, 50)}...`);
|
||||
} else {
|
||||
// Use provided systemPrompt or default
|
||||
let basePrompt = systemPrompt || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO;
|
||||
|
||||
// Check if any message has tool_calls or if useTools flag is set, indicating this is a tool-using conversation
|
||||
const hasPreviousToolCalls = messages.some(msg => msg.tool_calls && msg.tool_calls.length > 0);
|
||||
const hasToolResults = messages.some(msg => msg.role === 'tool');
|
||||
const isToolUsingConversation = useTools || hasPreviousToolCalls || hasToolResults;
|
||||
|
||||
// Add tool instructions for Ollama when tools are being used
|
||||
if (isToolUsingConversation && PROVIDER_PROMPTS.OLLAMA.TOOL_INSTRUCTIONS) {
|
||||
log.info('Adding tool instructions to system prompt for Ollama');
|
||||
basePrompt = `${basePrompt}\n\n${PROVIDER_PROMPTS.OLLAMA.TOOL_INSTRUCTIONS}`;
|
||||
}
|
||||
|
||||
formattedMessages.push({
|
||||
role: 'system',
|
||||
content: basePrompt
|
||||
});
|
||||
log.info(`Using new system message: ${basePrompt.substring(0, 50)}...`);
|
||||
}
|
||||
|
||||
// If we have context, inject it into the first user message
|
||||
if (context && nonSystemMessages.length > 0) {
|
||||
let injectedContext = false;
|
||||
|
||||
for (let i = 0; i < nonSystemMessages.length; i++) {
|
||||
const msg = nonSystemMessages[i];
|
||||
|
||||
if (msg.role === 'user' && !injectedContext) {
|
||||
// Simple context injection directly in the user's message
|
||||
const cleanedContext = this.cleanContextContent(context);
|
||||
log.info(`Injecting context (${cleanedContext.length} chars) into user message`);
|
||||
|
||||
const formattedContext = PROVIDER_PROMPTS.OLLAMA.CONTEXT_INJECTION(
|
||||
cleanedContext,
|
||||
msg.content
|
||||
);
|
||||
|
||||
// Log what properties we're preserving
|
||||
const msgKeys = Object.keys(msg);
|
||||
const preservedKeys = msgKeys.filter(key => key !== 'role' && key !== 'content');
|
||||
log.info(`Preserving additional properties in user message: ${preservedKeys.join(', ')}`);
|
||||
|
||||
// Create a new message with all original properties, but updated content
|
||||
const newMessage = {
|
||||
...msg, // Copy all properties
|
||||
content: formattedContext // Override content with injected context
|
||||
};
|
||||
|
||||
formattedMessages.push(newMessage);
|
||||
log.info(`Created user message with context, final keys: ${Object.keys(newMessage).join(', ')}`);
|
||||
|
||||
injectedContext = true;
|
||||
} else {
|
||||
// For other messages, preserve all properties including any tool-related ones
|
||||
log.info(`Preserving message with role ${msg.role}, keys: ${Object.keys(msg).join(', ')}`);
|
||||
|
||||
formattedMessages.push({
|
||||
...msg // Copy all properties
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No context, just add all messages as-is
|
||||
// Make sure to preserve all properties including tool_calls, tool_call_id, etc.
|
||||
for (const msg of nonSystemMessages) {
|
||||
log.info(`Adding message with role ${msg.role} without context injection, keys: ${Object.keys(msg).join(', ')}`);
|
||||
formattedMessages.push({
|
||||
...msg // Copy all properties
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Log the final formatted messages
|
||||
log.info(`Ollama formatter produced ${formattedMessages.length} formatted messages`);
|
||||
formattedMessages.forEach((msg, index) => {
|
||||
const msgKeys = Object.keys(msg);
|
||||
log.info(`Formatted message ${index} - role: ${msg.role}, keys: ${msgKeys.join(', ')}, content length: ${msg.content.length}`);
|
||||
|
||||
// Log special properties if present
|
||||
if (msg.tool_calls) {
|
||||
log.info(`Formatted message ${index} has ${msg.tool_calls.length} tool_calls`);
|
||||
}
|
||||
if (msg.tool_call_id) {
|
||||
log.info(`Formatted message ${index} has tool_call_id: ${msg.tool_call_id}`);
|
||||
}
|
||||
if (msg.name) {
|
||||
log.info(`Formatted message ${index} has name: ${msg.name}`);
|
||||
}
|
||||
});
|
||||
|
||||
return formattedMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up HTML and other problematic content before sending to Ollama
|
||||
* Ollama needs a more aggressive cleaning than other models,
|
||||
* but we want to preserve our XML tags for context
|
||||
*/
|
||||
override cleanContextContent(content: string): string {
|
||||
if (!content) return '';
|
||||
|
||||
try {
|
||||
// Define regexes for identifying and preserving tagged content
|
||||
const notesTagsRegex = /<\/?notes>/g;
|
||||
// const queryTagsRegex = /<\/?query>/g; // Commenting out unused variable
|
||||
|
||||
// Capture tags to restore later
|
||||
const noteTagPositions: number[] = [];
|
||||
let match;
|
||||
const regex = /<\/?note>/g;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
noteTagPositions.push(match.index);
|
||||
}
|
||||
|
||||
// Remember the notes tags
|
||||
const notesTagPositions: number[] = [];
|
||||
while ((match = notesTagsRegex.exec(content)) !== null) {
|
||||
notesTagPositions.push(match.index);
|
||||
}
|
||||
|
||||
// Remember the query tag
|
||||
|
||||
// Temporarily replace XML tags with markers that won't be affected by sanitization
|
||||
const modified = content
|
||||
.replace(/<note>/g, '[NOTE_START]')
|
||||
.replace(/<\/note>/g, '[NOTE_END]')
|
||||
.replace(/<notes>/g, '[NOTES_START]')
|
||||
.replace(/<\/notes>/g, '[NOTES_END]')
|
||||
.replace(/<query>(.*?)<\/query>/g, '[QUERY]$1[/QUERY]');
|
||||
|
||||
// First use the parent class to do standard cleaning
|
||||
const sanitized = super.cleanContextContent(modified);
|
||||
|
||||
// Then apply Ollama-specific aggressive cleaning
|
||||
// Remove any remaining HTML using sanitizeHtml while keeping our markers
|
||||
let plaintext = sanitizeHtml(sanitized, {
|
||||
allowedTags: HTML_ALLOWED_TAGS.NONE,
|
||||
allowedAttributes: HTML_ALLOWED_ATTRIBUTES.NONE,
|
||||
textFilter: (text) => text
|
||||
});
|
||||
|
||||
// Apply all Ollama-specific cleaning patterns
|
||||
const ollamaPatterns = OLLAMA_CLEANING;
|
||||
for (const pattern of Object.values(ollamaPatterns)) {
|
||||
plaintext = plaintext.replace(pattern.pattern, pattern.replacement);
|
||||
}
|
||||
|
||||
// Restore our XML tags
|
||||
plaintext = plaintext
|
||||
.replace(/\[NOTE_START\]/g, '<note>')
|
||||
.replace(/\[NOTE_END\]/g, '</note>')
|
||||
.replace(/\[NOTES_START\]/g, '<notes>')
|
||||
.replace(/\[NOTES_END\]/g, '</notes>')
|
||||
.replace(/\[QUERY\](.*?)\[\/QUERY\]/g, '<query>$1</query>');
|
||||
|
||||
return plaintext.trim();
|
||||
} catch (error) {
|
||||
console.error(FORMATTER_LOGS.ERROR.CONTEXT_CLEANING("Ollama"), error);
|
||||
return content; // Return original if cleaning fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum recommended context length for Ollama
|
||||
*/
|
||||
getMaxContextLength(): number {
|
||||
return OllamaMessageFormatter.MAX_CONTEXT_LENGTH;
|
||||
}
|
||||
}
|
||||
@ -1,143 +0,0 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import type { Message } from '../ai_interface.js';
|
||||
import { BaseMessageFormatter } from './base_formatter.js';
|
||||
import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js';
|
||||
import { LLM_CONSTANTS } from '../constants/provider_constants.js';
|
||||
import {
|
||||
HTML_ALLOWED_TAGS,
|
||||
HTML_ALLOWED_ATTRIBUTES,
|
||||
HTML_TO_MARKDOWN_PATTERNS,
|
||||
HTML_ENTITY_REPLACEMENTS,
|
||||
FORMATTER_LOGS
|
||||
} from '../constants/formatter_constants.js';
|
||||
import log from '../../log.js';
|
||||
|
||||
/**
|
||||
* OpenAI-specific message formatter
|
||||
* Optimized for OpenAI's API requirements and preferences
|
||||
*/
|
||||
export class OpenAIMessageFormatter extends BaseMessageFormatter {
|
||||
/**
|
||||
* Maximum recommended context length for OpenAI
|
||||
* Based on GPT-4 context window size
|
||||
*/
|
||||
private static MAX_CONTEXT_LENGTH = LLM_CONSTANTS.CONTEXT_WINDOW.OPENAI;
|
||||
|
||||
/**
|
||||
* Format messages for the OpenAI API
|
||||
* @param messages The messages to format
|
||||
* @param systemPrompt Optional system prompt to use
|
||||
* @param context Optional context to include
|
||||
* @param preserveSystemPrompt When true, preserves existing system messages
|
||||
* @param useTools Flag indicating if tools will be used in this request
|
||||
*/
|
||||
formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean, useTools?: boolean): Message[] {
|
||||
const formattedMessages: Message[] = [];
|
||||
|
||||
// Check if we already have a system message
|
||||
const hasSystemMessage = messages.some(msg => msg.role === 'system');
|
||||
const userAssistantMessages = messages.filter(msg => msg.role === 'user' || msg.role === 'assistant');
|
||||
|
||||
// If we have explicit context, format it properly
|
||||
if (context) {
|
||||
// For OpenAI, it's best to put context in the system message
|
||||
const formattedContext = PROVIDER_PROMPTS.OPENAI.SYSTEM_WITH_CONTEXT(
|
||||
this.cleanContextContent(context)
|
||||
);
|
||||
|
||||
// Add as system message
|
||||
formattedMessages.push({
|
||||
role: 'system',
|
||||
content: formattedContext
|
||||
});
|
||||
}
|
||||
// If we don't have explicit context but have a system prompt
|
||||
else if (!hasSystemMessage && systemPrompt) {
|
||||
let baseSystemPrompt = systemPrompt || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO;
|
||||
|
||||
// Check if this is a tool-using conversation
|
||||
const hasPreviousToolCalls = messages.some(msg => msg.tool_calls && msg.tool_calls.length > 0);
|
||||
const hasToolResults = messages.some(msg => msg.role === 'tool');
|
||||
const isToolUsingConversation = useTools || hasPreviousToolCalls || hasToolResults;
|
||||
|
||||
// Add tool instructions for OpenAI when tools are being used
|
||||
if (isToolUsingConversation && PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS) {
|
||||
log.info('Adding tool instructions to system prompt for OpenAI');
|
||||
baseSystemPrompt = `${baseSystemPrompt}\n\n${PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS}`;
|
||||
}
|
||||
|
||||
formattedMessages.push({
|
||||
role: 'system',
|
||||
content: baseSystemPrompt
|
||||
});
|
||||
}
|
||||
// If neither context nor system prompt is provided, use default system prompt
|
||||
else if (!hasSystemMessage) {
|
||||
formattedMessages.push({
|
||||
role: 'system',
|
||||
content: this.getDefaultSystemPrompt(systemPrompt)
|
||||
});
|
||||
}
|
||||
// Otherwise if there are existing system messages, keep them
|
||||
else if (hasSystemMessage) {
|
||||
// Keep any existing system messages
|
||||
const systemMessages = messages.filter(msg => msg.role === 'system');
|
||||
for (const msg of systemMessages) {
|
||||
formattedMessages.push({
|
||||
role: 'system',
|
||||
content: this.cleanContextContent(msg.content)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add all user and assistant messages
|
||||
for (const msg of userAssistantMessages) {
|
||||
formattedMessages.push({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
});
|
||||
}
|
||||
|
||||
console.log(FORMATTER_LOGS.OPENAI.PROCESSED(messages.length, formattedMessages.length));
|
||||
return formattedMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean context content for OpenAI
|
||||
* OpenAI handles HTML better than Ollama but still benefits from some cleaning
|
||||
*/
|
||||
override cleanContextContent(content: string): string {
|
||||
if (!content) return '';
|
||||
|
||||
try {
|
||||
// Convert HTML to Markdown for better readability
|
||||
const cleaned = sanitizeHtml(content, {
|
||||
allowedTags: HTML_ALLOWED_TAGS.STANDARD,
|
||||
allowedAttributes: HTML_ALLOWED_ATTRIBUTES.STANDARD
|
||||
});
|
||||
|
||||
// Apply all HTML to Markdown patterns
|
||||
let markdown = cleaned;
|
||||
for (const pattern of Object.values(HTML_TO_MARKDOWN_PATTERNS)) {
|
||||
markdown = markdown.replace(pattern.pattern, pattern.replacement);
|
||||
}
|
||||
|
||||
// Fix common HTML entities
|
||||
for (const pattern of Object.values(HTML_ENTITY_REPLACEMENTS)) {
|
||||
markdown = markdown.replace(pattern.pattern, pattern.replacement);
|
||||
}
|
||||
|
||||
return markdown.trim();
|
||||
} catch (error) {
|
||||
console.error(FORMATTER_LOGS.ERROR.CONTEXT_CLEANING("OpenAI"), error);
|
||||
return content; // Return original if cleaning fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum recommended context length for OpenAI
|
||||
*/
|
||||
getMaxContextLength(): number {
|
||||
return OpenAIMessageFormatter.MAX_CONTEXT_LENGTH;
|
||||
}
|
||||
}
|
||||
@ -1,429 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ChatPipeline } from './chat_pipeline.js';
|
||||
import type { ChatPipelineInput, ChatPipelineConfig } from './interfaces.js';
|
||||
import type { Message, ChatResponse } from '../ai_interface.js';
|
||||
|
||||
// Mock all pipeline stages as classes that can be instantiated
|
||||
vi.mock('./stages/context_extraction_stage.js', () => {
|
||||
class MockContextExtractionStage {
|
||||
execute = vi.fn().mockResolvedValue({});
|
||||
}
|
||||
return { ContextExtractionStage: MockContextExtractionStage };
|
||||
});
|
||||
|
||||
vi.mock('./stages/semantic_context_extraction_stage.js', () => {
|
||||
class MockSemanticContextExtractionStage {
|
||||
execute = vi.fn().mockResolvedValue({
|
||||
context: ''
|
||||
});
|
||||
}
|
||||
return { SemanticContextExtractionStage: MockSemanticContextExtractionStage };
|
||||
});
|
||||
|
||||
vi.mock('./stages/agent_tools_context_stage.js', () => {
|
||||
class MockAgentToolsContextStage {
|
||||
execute = vi.fn().mockResolvedValue({});
|
||||
}
|
||||
return { AgentToolsContextStage: MockAgentToolsContextStage };
|
||||
});
|
||||
|
||||
vi.mock('./stages/message_preparation_stage.js', () => {
|
||||
class MockMessagePreparationStage {
|
||||
execute = vi.fn().mockResolvedValue({
|
||||
messages: [{ role: 'user', content: 'Hello' }]
|
||||
});
|
||||
}
|
||||
return { MessagePreparationStage: MockMessagePreparationStage };
|
||||
});
|
||||
|
||||
vi.mock('./stages/model_selection_stage.js', () => {
|
||||
class MockModelSelectionStage {
|
||||
execute = vi.fn().mockResolvedValue({
|
||||
options: {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
enableTools: true,
|
||||
stream: false
|
||||
}
|
||||
});
|
||||
}
|
||||
return { ModelSelectionStage: MockModelSelectionStage };
|
||||
});
|
||||
|
||||
vi.mock('./stages/llm_completion_stage.js', () => {
|
||||
class MockLLMCompletionStage {
|
||||
execute = vi.fn().mockResolvedValue({
|
||||
response: {
|
||||
text: 'Hello! How can I help you?',
|
||||
role: 'assistant',
|
||||
finish_reason: 'stop'
|
||||
}
|
||||
});
|
||||
}
|
||||
return { LLMCompletionStage: MockLLMCompletionStage };
|
||||
});
|
||||
|
||||
vi.mock('./stages/response_processing_stage.js', () => {
|
||||
class MockResponseProcessingStage {
|
||||
execute = vi.fn().mockResolvedValue({
|
||||
text: 'Hello! How can I help you?'
|
||||
});
|
||||
}
|
||||
return { ResponseProcessingStage: MockResponseProcessingStage };
|
||||
});
|
||||
|
||||
vi.mock('./stages/tool_calling_stage.js', () => {
|
||||
class MockToolCallingStage {
|
||||
execute = vi.fn().mockResolvedValue({
|
||||
needsFollowUp: false,
|
||||
messages: []
|
||||
});
|
||||
}
|
||||
return { ToolCallingStage: MockToolCallingStage };
|
||||
});
|
||||
|
||||
vi.mock('../tools/tool_registry.js', () => ({
|
||||
default: {
|
||||
getTools: vi.fn().mockReturnValue([]),
|
||||
executeTool: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../tools/tool_initializer.js', () => ({
|
||||
default: {
|
||||
initializeTools: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../ai_service_manager.js', () => ({
|
||||
default: {
|
||||
getService: vi.fn().mockReturnValue({
|
||||
decomposeQuery: vi.fn().mockResolvedValue({
|
||||
subQueries: [{ text: 'test query' }],
|
||||
complexity: 3
|
||||
})
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../context/services/query_processor.js', () => ({
|
||||
default: {
|
||||
decomposeQuery: vi.fn().mockResolvedValue({
|
||||
subQueries: [{ text: 'test query' }],
|
||||
complexity: 3
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../constants/search_constants.js', () => ({
|
||||
SEARCH_CONSTANTS: {
|
||||
TOOL_EXECUTION: {
|
||||
MAX_TOOL_CALL_ITERATIONS: 5
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../log.js', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('ChatPipeline', () => {
|
||||
let pipeline: ChatPipeline;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pipeline = new ChatPipeline();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with default configuration', () => {
|
||||
expect(pipeline.config).toEqual({
|
||||
enableStreaming: true,
|
||||
enableMetrics: true,
|
||||
maxToolCallIterations: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept custom configuration', () => {
|
||||
const customConfig: Partial<ChatPipelineConfig> = {
|
||||
enableStreaming: false,
|
||||
maxToolCallIterations: 5
|
||||
};
|
||||
|
||||
const customPipeline = new ChatPipeline(customConfig);
|
||||
|
||||
expect(customPipeline.config).toEqual({
|
||||
enableStreaming: false,
|
||||
enableMetrics: true,
|
||||
maxToolCallIterations: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize all pipeline stages', () => {
|
||||
expect(pipeline.stages.contextExtraction).toBeDefined();
|
||||
expect(pipeline.stages.semanticContextExtraction).toBeDefined();
|
||||
expect(pipeline.stages.agentToolsContext).toBeDefined();
|
||||
expect(pipeline.stages.messagePreparation).toBeDefined();
|
||||
expect(pipeline.stages.modelSelection).toBeDefined();
|
||||
expect(pipeline.stages.llmCompletion).toBeDefined();
|
||||
expect(pipeline.stages.responseProcessing).toBeDefined();
|
||||
expect(pipeline.stages.toolCalling).toBeDefined();
|
||||
});
|
||||
|
||||
it('should initialize metrics', () => {
|
||||
expect(pipeline.metrics).toEqual({
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0,
|
||||
stageMetrics: {
|
||||
contextExtraction: {
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0
|
||||
},
|
||||
semanticContextExtraction: {
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0
|
||||
},
|
||||
agentToolsContext: {
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0
|
||||
},
|
||||
messagePreparation: {
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0
|
||||
},
|
||||
modelSelection: {
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0
|
||||
},
|
||||
llmCompletion: {
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0
|
||||
},
|
||||
responseProcessing: {
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0
|
||||
},
|
||||
toolCalling: {
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' }
|
||||
];
|
||||
|
||||
const input: ChatPipelineInput = {
|
||||
query: 'Hello',
|
||||
messages,
|
||||
options: {
|
||||
useAdvancedContext: true // Enable advanced context to trigger full pipeline flow
|
||||
},
|
||||
noteId: 'note-123'
|
||||
};
|
||||
|
||||
it('should execute all pipeline stages in order', async () => {
|
||||
const result = await pipeline.execute(input);
|
||||
|
||||
// Get the mock instances from the pipeline stages
|
||||
expect(pipeline.stages.modelSelection.execute).toHaveBeenCalled();
|
||||
expect(pipeline.stages.messagePreparation.execute).toHaveBeenCalled();
|
||||
expect(pipeline.stages.llmCompletion.execute).toHaveBeenCalled();
|
||||
expect(pipeline.stages.responseProcessing.execute).toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({
|
||||
text: 'Hello! How can I help you?',
|
||||
role: 'assistant',
|
||||
finish_reason: 'stop'
|
||||
});
|
||||
});
|
||||
|
||||
it('should increment total executions metric', async () => {
|
||||
const initialExecutions = pipeline.metrics.totalExecutions;
|
||||
|
||||
await pipeline.execute(input);
|
||||
|
||||
expect(pipeline.metrics.totalExecutions).toBe(initialExecutions + 1);
|
||||
});
|
||||
|
||||
it('should handle streaming callback', async () => {
|
||||
const streamCallback = vi.fn();
|
||||
const inputWithStream = { ...input, streamCallback };
|
||||
|
||||
await pipeline.execute(inputWithStream);
|
||||
|
||||
expect(pipeline.stages.llmCompletion.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle tool calling iterations', async () => {
|
||||
// Mock LLM response to include tool calls
|
||||
(pipeline.stages.llmCompletion.execute as any).mockResolvedValue({
|
||||
response: {
|
||||
text: 'Hello! How can I help you?',
|
||||
role: 'assistant',
|
||||
finish_reason: 'stop',
|
||||
tool_calls: [{ id: 'tool1', function: { name: 'search', arguments: '{}' } }]
|
||||
}
|
||||
});
|
||||
|
||||
// Mock tool calling to require iteration then stop
|
||||
(pipeline.stages.toolCalling.execute as any)
|
||||
.mockResolvedValueOnce({ needsFollowUp: true, messages: [] })
|
||||
.mockResolvedValueOnce({ needsFollowUp: false, messages: [] });
|
||||
|
||||
await pipeline.execute(input);
|
||||
|
||||
expect(pipeline.stages.toolCalling.execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should respect max tool call iterations', async () => {
|
||||
// Mock LLM response to include tool calls
|
||||
(pipeline.stages.llmCompletion.execute as any).mockResolvedValue({
|
||||
response: {
|
||||
text: 'Hello! How can I help you?',
|
||||
role: 'assistant',
|
||||
finish_reason: 'stop',
|
||||
tool_calls: [{ id: 'tool1', function: { name: 'search', arguments: '{}' } }]
|
||||
}
|
||||
});
|
||||
|
||||
// Mock tool calling to always require iteration
|
||||
(pipeline.stages.toolCalling.execute as any).mockResolvedValue({ needsFollowUp: true, messages: [] });
|
||||
|
||||
await pipeline.execute(input);
|
||||
|
||||
// Should be called maxToolCallIterations times (5 iterations as configured)
|
||||
expect(pipeline.stages.toolCalling.execute).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('should handle stage errors gracefully', async () => {
|
||||
(pipeline.stages.modelSelection.execute as any).mockRejectedValueOnce(new Error('Model selection failed'));
|
||||
|
||||
await expect(pipeline.execute(input)).rejects.toThrow('Model selection failed');
|
||||
});
|
||||
|
||||
it('should pass context between stages', async () => {
|
||||
await pipeline.execute(input);
|
||||
|
||||
// Check that stage was called (the actual context passing is tested in integration)
|
||||
expect(pipeline.stages.messagePreparation.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty messages', async () => {
|
||||
const emptyInput = { ...input, messages: [] };
|
||||
|
||||
const result = await pipeline.execute(emptyInput);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(pipeline.stages.modelSelection.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should calculate content length for model selection', async () => {
|
||||
await pipeline.execute(input);
|
||||
|
||||
expect(pipeline.stages.modelSelection.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contentLength: expect.any(Number)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should update average execution time', async () => {
|
||||
const initialAverage = pipeline.metrics.averageExecutionTime;
|
||||
|
||||
await pipeline.execute(input);
|
||||
|
||||
expect(pipeline.metrics.averageExecutionTime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should disable streaming when config is false', async () => {
|
||||
const noStreamPipeline = new ChatPipeline({ enableStreaming: false });
|
||||
|
||||
await noStreamPipeline.execute(input);
|
||||
|
||||
expect(noStreamPipeline.stages.llmCompletion.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle concurrent executions', async () => {
|
||||
const promise1 = pipeline.execute(input);
|
||||
const promise2 = pipeline.execute(input);
|
||||
|
||||
const [result1, result2] = await Promise.all([promise1, promise2]);
|
||||
|
||||
expect(result1).toBeDefined();
|
||||
expect(result2).toBeDefined();
|
||||
expect(pipeline.metrics.totalExecutions).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('metrics', () => {
|
||||
const input: ChatPipelineInput = {
|
||||
query: 'Hello',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
options: {
|
||||
useAdvancedContext: true
|
||||
},
|
||||
noteId: 'note-123'
|
||||
};
|
||||
|
||||
it('should track stage execution times when metrics enabled', async () => {
|
||||
await pipeline.execute(input);
|
||||
|
||||
expect(pipeline.metrics.stageMetrics.modelSelection.totalExecutions).toBe(1);
|
||||
expect(pipeline.metrics.stageMetrics.llmCompletion.totalExecutions).toBe(1);
|
||||
});
|
||||
|
||||
it('should skip stage metrics when disabled', async () => {
|
||||
const noMetricsPipeline = new ChatPipeline({ enableMetrics: false });
|
||||
|
||||
await noMetricsPipeline.execute(input);
|
||||
|
||||
// Total executions is still tracked, but stage metrics are not updated
|
||||
expect(noMetricsPipeline.metrics.totalExecutions).toBe(1);
|
||||
expect(noMetricsPipeline.metrics.stageMetrics.modelSelection.totalExecutions).toBe(0);
|
||||
expect(noMetricsPipeline.metrics.stageMetrics.llmCompletion.totalExecutions).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
const input: ChatPipelineInput = {
|
||||
query: 'Hello',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
options: {
|
||||
useAdvancedContext: true
|
||||
},
|
||||
noteId: 'note-123'
|
||||
};
|
||||
|
||||
it('should propagate errors from stages', async () => {
|
||||
(pipeline.stages.modelSelection.execute as any).mockRejectedValueOnce(new Error('Model selection failed'));
|
||||
|
||||
await expect(pipeline.execute(input)).rejects.toThrow('Model selection failed');
|
||||
});
|
||||
|
||||
it('should handle invalid input gracefully', async () => {
|
||||
const invalidInput = {
|
||||
query: '',
|
||||
messages: [],
|
||||
options: {},
|
||||
noteId: ''
|
||||
};
|
||||
|
||||
const result = await pipeline.execute(invalidInput);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,986 +0,0 @@
|
||||
import type { ChatPipelineInput, ChatPipelineConfig, PipelineMetrics, StreamCallback } from './interfaces.js';
|
||||
import type { ChatResponse, StreamChunk, Message } from '../ai_interface.js';
|
||||
import { ContextExtractionStage } from './stages/context_extraction_stage.js';
|
||||
import { SemanticContextExtractionStage } from './stages/semantic_context_extraction_stage.js';
|
||||
import { AgentToolsContextStage } from './stages/agent_tools_context_stage.js';
|
||||
import { MessagePreparationStage } from './stages/message_preparation_stage.js';
|
||||
import { ModelSelectionStage } from './stages/model_selection_stage.js';
|
||||
import { LLMCompletionStage } from './stages/llm_completion_stage.js';
|
||||
import { ResponseProcessingStage } from './stages/response_processing_stage.js';
|
||||
import { ToolCallingStage } from './stages/tool_calling_stage.js';
|
||||
import { ErrorRecoveryStage } from './stages/error_recovery_stage.js';
|
||||
// Traditional search is used instead of vector search
|
||||
import toolRegistry from '../tools/tool_registry.js';
|
||||
import toolInitializer from '../tools/tool_initializer.js';
|
||||
import log from '../../log.js';
|
||||
import type { LLMServiceInterface } from '../interfaces/agent_tool_interfaces.js';
|
||||
import { SEARCH_CONSTANTS } from '../constants/search_constants.js';
|
||||
|
||||
/**
|
||||
* Pipeline for managing the entire chat flow
|
||||
* Implements a modular, composable architecture where each stage is a separate component
|
||||
*/
|
||||
export class ChatPipeline {
|
||||
stages: {
|
||||
contextExtraction: ContextExtractionStage;
|
||||
semanticContextExtraction: SemanticContextExtractionStage;
|
||||
agentToolsContext: AgentToolsContextStage;
|
||||
messagePreparation: MessagePreparationStage;
|
||||
modelSelection: ModelSelectionStage;
|
||||
llmCompletion: LLMCompletionStage;
|
||||
responseProcessing: ResponseProcessingStage;
|
||||
toolCalling: ToolCallingStage;
|
||||
errorRecovery: ErrorRecoveryStage;
|
||||
// traditional search is used instead of vector search
|
||||
};
|
||||
|
||||
config: ChatPipelineConfig;
|
||||
metrics: PipelineMetrics;
|
||||
|
||||
/**
|
||||
* Create a new chat pipeline
|
||||
* @param config Optional pipeline configuration
|
||||
*/
|
||||
constructor(config?: Partial<ChatPipelineConfig>) {
|
||||
// Initialize all pipeline stages
|
||||
this.stages = {
|
||||
contextExtraction: new ContextExtractionStage(),
|
||||
semanticContextExtraction: new SemanticContextExtractionStage(),
|
||||
agentToolsContext: new AgentToolsContextStage(),
|
||||
messagePreparation: new MessagePreparationStage(),
|
||||
modelSelection: new ModelSelectionStage(),
|
||||
llmCompletion: new LLMCompletionStage(),
|
||||
responseProcessing: new ResponseProcessingStage(),
|
||||
toolCalling: new ToolCallingStage(),
|
||||
errorRecovery: new ErrorRecoveryStage(),
|
||||
// traditional search is used instead of vector search
|
||||
};
|
||||
|
||||
// Set default configuration values
|
||||
this.config = {
|
||||
enableStreaming: true,
|
||||
enableMetrics: true,
|
||||
maxToolCallIterations: SEARCH_CONSTANTS.TOOL_EXECUTION.MAX_TOOL_CALL_ITERATIONS,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize metrics
|
||||
this.metrics = {
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0,
|
||||
stageMetrics: {}
|
||||
};
|
||||
|
||||
// Initialize stage metrics
|
||||
Object.keys(this.stages).forEach(stageName => {
|
||||
this.metrics.stageMetrics[stageName] = {
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the chat pipeline
|
||||
* This is the main entry point that orchestrates all pipeline stages
|
||||
*/
|
||||
async execute(input: ChatPipelineInput): Promise<ChatResponse> {
|
||||
log.info(`========== STARTING CHAT PIPELINE ==========`);
|
||||
log.info(`Executing chat pipeline with ${input.messages.length} messages`);
|
||||
const startTime = Date.now();
|
||||
this.metrics.totalExecutions++;
|
||||
|
||||
// Initialize streaming handler if requested
|
||||
let streamCallback = input.streamCallback;
|
||||
let accumulatedText = '';
|
||||
|
||||
try {
|
||||
// Extract content length for model selection
|
||||
let contentLength = 0;
|
||||
for (const message of input.messages) {
|
||||
contentLength += message.content.length;
|
||||
}
|
||||
|
||||
// Initialize tools if needed
|
||||
try {
|
||||
const toolCount = toolRegistry.getAllTools().length;
|
||||
|
||||
// If there are no tools registered, initialize them
|
||||
if (toolCount === 0) {
|
||||
log.info('No tools found in registry, initializing tools...');
|
||||
// Tools are already initialized in the AIServiceManager constructor
|
||||
// No need to initialize them again
|
||||
log.info(`Tools initialized, now have ${toolRegistry.getAllTools().length} tools`);
|
||||
} else {
|
||||
log.info(`Found ${toolCount} tools already registered`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`Error checking/initializing tools: ${error.message || String(error)}`);
|
||||
}
|
||||
|
||||
// First, select the appropriate model based on query complexity and content length
|
||||
const modelSelectionStartTime = Date.now();
|
||||
log.info(`========== MODEL SELECTION ==========`);
|
||||
const modelSelection = await this.stages.modelSelection.execute({
|
||||
options: input.options,
|
||||
query: input.query,
|
||||
contentLength
|
||||
});
|
||||
this.updateStageMetrics('modelSelection', modelSelectionStartTime);
|
||||
log.info(`Selected model: ${modelSelection.options.model || 'default'}, enableTools: ${modelSelection.options.enableTools}`);
|
||||
|
||||
// Determine if we should use tools or semantic context
|
||||
const useTools = modelSelection.options.enableTools === true;
|
||||
const useEnhancedContext = input.options?.useAdvancedContext === true;
|
||||
|
||||
// Log details about the advanced context parameter
|
||||
log.info(`Enhanced context option check: input.options=${JSON.stringify(input.options || {})}`);
|
||||
log.info(`Enhanced context decision: useEnhancedContext=${useEnhancedContext}, hasQuery=${!!input.query}`);
|
||||
|
||||
// Early return if we don't have a query or enhanced context is disabled
|
||||
if (!input.query || !useEnhancedContext) {
|
||||
log.info(`========== SIMPLE QUERY MODE ==========`);
|
||||
log.info('Enhanced context disabled or no query provided, skipping context enrichment');
|
||||
|
||||
// Prepare messages without additional context
|
||||
const messagePreparationStartTime = Date.now();
|
||||
const preparedMessages = await this.stages.messagePreparation.execute({
|
||||
messages: input.messages,
|
||||
systemPrompt: input.options?.systemPrompt,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('messagePreparation', messagePreparationStartTime);
|
||||
|
||||
// Generate completion using the LLM
|
||||
const llmStartTime = Date.now();
|
||||
const completion = await this.stages.llmCompletion.execute({
|
||||
messages: preparedMessages.messages,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('llmCompletion', llmStartTime);
|
||||
|
||||
return completion.response;
|
||||
}
|
||||
|
||||
// STAGE 1: Start with the user's query
|
||||
const userQuery = input.query || '';
|
||||
log.info(`========== STAGE 1: USER QUERY ==========`);
|
||||
log.info(`Processing query with: question="${userQuery.substring(0, 50)}...", noteId=${input.noteId}, showThinking=${input.showThinking}`);
|
||||
|
||||
// STAGE 2: Perform query decomposition using the LLM
|
||||
log.info(`========== STAGE 2: QUERY DECOMPOSITION ==========`);
|
||||
log.info('Performing query decomposition to generate effective search queries');
|
||||
const llmService = await this.getLLMService();
|
||||
let searchQueries = [userQuery];
|
||||
|
||||
if (llmService) {
|
||||
try {
|
||||
// Import the query processor and use its decomposeQuery method
|
||||
const queryProcessor = (await import('../context/services/query_processor.js')).default;
|
||||
|
||||
// Use the enhanced query processor with the LLM service
|
||||
const decomposedQuery = await queryProcessor.decomposeQuery(userQuery, undefined, llmService);
|
||||
|
||||
if (decomposedQuery && decomposedQuery.subQueries && decomposedQuery.subQueries.length > 0) {
|
||||
// Extract search queries from the decomposed query
|
||||
searchQueries = decomposedQuery.subQueries.map(sq => sq.text);
|
||||
|
||||
// Always include the original query if it's not already included
|
||||
if (!searchQueries.includes(userQuery)) {
|
||||
searchQueries.unshift(userQuery);
|
||||
}
|
||||
|
||||
log.info(`Query decomposed with complexity ${decomposedQuery.complexity}/10 into ${searchQueries.length} search queries`);
|
||||
} else {
|
||||
log.info('Query decomposition returned no sub-queries, using original query');
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`Error in query decomposition: ${error.message || String(error)}`);
|
||||
}
|
||||
} else {
|
||||
log.info('No LLM service available for query decomposition, using original query');
|
||||
}
|
||||
|
||||
// STAGE 3: Vector search has been removed - skip semantic search
|
||||
const vectorSearchStartTime = Date.now();
|
||||
log.info(`========== STAGE 3: VECTOR SEARCH (DISABLED) ==========`);
|
||||
log.info('Vector search has been removed - LLM will rely on tool calls for context');
|
||||
|
||||
// Create empty vector search result since vector search is disabled
|
||||
const vectorSearchResult = {
|
||||
searchResults: [],
|
||||
totalResults: 0,
|
||||
executionTime: Date.now() - vectorSearchStartTime
|
||||
};
|
||||
|
||||
// Skip metrics update for disabled vector search functionality
|
||||
log.info(`Vector search disabled - using tool-based context extraction instead`);
|
||||
|
||||
// Extract context from search results
|
||||
log.info(`========== SEMANTIC CONTEXT EXTRACTION ==========`);
|
||||
const semanticContextStartTime = Date.now();
|
||||
const semanticContext = await this.stages.semanticContextExtraction.execute({
|
||||
noteId: input.noteId || 'global',
|
||||
query: userQuery,
|
||||
messages: input.messages,
|
||||
searchResults: vectorSearchResult.searchResults
|
||||
});
|
||||
|
||||
const context = semanticContext.context;
|
||||
this.updateStageMetrics('semanticContextExtraction', semanticContextStartTime);
|
||||
log.info(`Extracted semantic context (${context.length} chars)`);
|
||||
|
||||
// STAGE 4: Prepare messages with context and tool definitions for the LLM
|
||||
log.info(`========== STAGE 4: MESSAGE PREPARATION ==========`);
|
||||
const messagePreparationStartTime = Date.now();
|
||||
const preparedMessages = await this.stages.messagePreparation.execute({
|
||||
messages: input.messages,
|
||||
context,
|
||||
systemPrompt: input.options?.systemPrompt,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('messagePreparation', messagePreparationStartTime);
|
||||
log.info(`Prepared ${preparedMessages.messages.length} messages for LLM, tools enabled: ${useTools}`);
|
||||
|
||||
// Setup streaming handler if streaming is enabled and callback provided
|
||||
// Check if streaming should be enabled based on several conditions
|
||||
const streamEnabledInConfig = this.config.enableStreaming;
|
||||
const streamFormatRequested = input.format === 'stream';
|
||||
const streamRequestedInOptions = modelSelection.options.stream === true;
|
||||
const streamCallbackAvailable = typeof streamCallback === 'function';
|
||||
|
||||
log.info(`[ChatPipeline] Request type info - Format: ${input.format || 'not specified'}, Options from pipelineInput: ${JSON.stringify({stream: input.options?.stream})}`);
|
||||
log.info(`[ChatPipeline] Stream settings - config.enableStreaming: ${streamEnabledInConfig}, format parameter: ${input.format}, modelSelection.options.stream: ${modelSelection.options.stream}, streamCallback available: ${streamCallbackAvailable}`);
|
||||
|
||||
// IMPORTANT: Respect the existing stream option but with special handling for callbacks:
|
||||
// 1. If a stream callback is available, streaming MUST be enabled for it to work
|
||||
// 2. Otherwise, preserve the original stream setting from input options
|
||||
|
||||
// First, determine what the stream value should be based on various factors:
|
||||
let shouldEnableStream = modelSelection.options.stream;
|
||||
|
||||
if (streamCallbackAvailable) {
|
||||
// If we have a stream callback, we NEED to enable streaming
|
||||
// This is critical for GET requests with EventSource
|
||||
shouldEnableStream = true;
|
||||
log.info(`[ChatPipeline] Stream callback available, enabling streaming`);
|
||||
} else if (streamRequestedInOptions) {
|
||||
// Stream was explicitly requested in options, honor that setting
|
||||
log.info(`[ChatPipeline] Stream explicitly requested in options: ${streamRequestedInOptions}`);
|
||||
shouldEnableStream = streamRequestedInOptions;
|
||||
} else if (streamFormatRequested) {
|
||||
// Format=stream parameter indicates streaming was requested
|
||||
log.info(`[ChatPipeline] Stream format requested in parameters`);
|
||||
shouldEnableStream = true;
|
||||
} else {
|
||||
// No explicit streaming indicators, use config default
|
||||
log.info(`[ChatPipeline] No explicit stream settings, using config default: ${streamEnabledInConfig}`);
|
||||
shouldEnableStream = streamEnabledInConfig;
|
||||
}
|
||||
|
||||
// Set the final stream option
|
||||
modelSelection.options.stream = shouldEnableStream;
|
||||
|
||||
log.info(`[ChatPipeline] Final streaming decision: stream=${shouldEnableStream}, will stream to client=${streamCallbackAvailable && shouldEnableStream}`);
|
||||
|
||||
|
||||
// STAGE 5 & 6: Handle LLM completion and tool execution loop
|
||||
log.info(`========== STAGE 5: LLM COMPLETION ==========`);
|
||||
const llmStartTime = Date.now();
|
||||
const completion = await this.stages.llmCompletion.execute({
|
||||
messages: preparedMessages.messages,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('llmCompletion', llmStartTime);
|
||||
log.info(`Received LLM response from model: ${completion.response.model}, provider: ${completion.response.provider}`);
|
||||
|
||||
// Track whether content has been streamed to prevent duplication
|
||||
let hasStreamedContent = false;
|
||||
|
||||
// Handle streaming if enabled and available
|
||||
// Use shouldEnableStream variable which contains our streaming decision
|
||||
if (shouldEnableStream && completion.response.stream && streamCallback) {
|
||||
// Setup stream handler that passes chunks through response processing
|
||||
await completion.response.stream(async (chunk: StreamChunk) => {
|
||||
// Process the chunk text
|
||||
const processedChunk = await this.processStreamChunk(chunk, input.options);
|
||||
|
||||
// Accumulate text for final response
|
||||
accumulatedText += processedChunk.text;
|
||||
|
||||
// Forward to callback with original chunk data in case it contains additional information
|
||||
streamCallback(processedChunk.text, processedChunk.done, chunk);
|
||||
|
||||
// Mark that we have streamed content to prevent duplication
|
||||
hasStreamedContent = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Process any tool calls in the response
|
||||
let currentMessages = preparedMessages.messages;
|
||||
let currentResponse = completion.response;
|
||||
let toolCallIterations = 0;
|
||||
const maxToolCallIterations = this.config.maxToolCallIterations;
|
||||
|
||||
// Check if tools were enabled in the options
|
||||
const toolsEnabled = modelSelection.options.enableTools !== false;
|
||||
|
||||
// Log decision points for tool execution
|
||||
log.info(`========== TOOL EXECUTION DECISION ==========`);
|
||||
log.info(`Tools enabled in options: ${toolsEnabled}`);
|
||||
log.info(`Response provider: ${currentResponse.provider || 'unknown'}`);
|
||||
log.info(`Response model: ${currentResponse.model || 'unknown'}`);
|
||||
|
||||
// Enhanced tool_calls detection - check both direct property and getter
|
||||
let hasToolCalls = false;
|
||||
|
||||
log.info(`[TOOL CALL DEBUG] Starting tool call detection for provider: ${currentResponse.provider}`);
|
||||
// Check response object structure
|
||||
log.info(`[TOOL CALL DEBUG] Response properties: ${Object.keys(currentResponse).join(', ')}`);
|
||||
|
||||
// Try to access tool_calls as a property
|
||||
if ('tool_calls' in currentResponse) {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls exists as a direct property`);
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls type: ${typeof currentResponse.tool_calls}`);
|
||||
|
||||
if (currentResponse.tool_calls && Array.isArray(currentResponse.tool_calls)) {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls is an array with length: ${currentResponse.tool_calls.length}`);
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls is not an array or is empty: ${JSON.stringify(currentResponse.tool_calls)}`);
|
||||
}
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls does not exist as a direct property`);
|
||||
}
|
||||
|
||||
// First check the direct property
|
||||
if (currentResponse.tool_calls && currentResponse.tool_calls.length > 0) {
|
||||
hasToolCalls = true;
|
||||
log.info(`Response has tool_calls property with ${currentResponse.tool_calls.length} tools`);
|
||||
log.info(`Tool calls details: ${JSON.stringify(currentResponse.tool_calls)}`);
|
||||
}
|
||||
// Check if it might be a getter (for dynamic tool_calls collection)
|
||||
else {
|
||||
log.info(`[TOOL CALL DEBUG] Direct property check failed, trying getter approach`);
|
||||
try {
|
||||
const toolCallsDesc = Object.getOwnPropertyDescriptor(currentResponse, 'tool_calls');
|
||||
|
||||
if (toolCallsDesc) {
|
||||
log.info(`[TOOL CALL DEBUG] Found property descriptor for tool_calls: ${JSON.stringify({
|
||||
configurable: toolCallsDesc.configurable,
|
||||
enumerable: toolCallsDesc.enumerable,
|
||||
hasGetter: !!toolCallsDesc.get,
|
||||
hasSetter: !!toolCallsDesc.set
|
||||
})}`);
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] No property descriptor found for tool_calls`);
|
||||
}
|
||||
|
||||
if (toolCallsDesc && typeof toolCallsDesc.get === 'function') {
|
||||
log.info(`[TOOL CALL DEBUG] Attempting to call the tool_calls getter`);
|
||||
const dynamicToolCalls = toolCallsDesc.get.call(currentResponse);
|
||||
|
||||
log.info(`[TOOL CALL DEBUG] Getter returned: ${JSON.stringify(dynamicToolCalls)}`);
|
||||
|
||||
if (dynamicToolCalls && dynamicToolCalls.length > 0) {
|
||||
hasToolCalls = true;
|
||||
log.info(`Response has dynamic tool_calls with ${dynamicToolCalls.length} tools`);
|
||||
log.info(`Dynamic tool calls details: ${JSON.stringify(dynamicToolCalls)}`);
|
||||
// Ensure property is available for subsequent code
|
||||
currentResponse.tool_calls = dynamicToolCalls;
|
||||
log.info(`[TOOL CALL DEBUG] Updated currentResponse.tool_calls with dynamic values`);
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] Getter returned no valid tool calls`);
|
||||
}
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] No getter function found for tool_calls`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error(`Error checking dynamic tool_calls: ${e}`);
|
||||
log.error(`[TOOL CALL DEBUG] Error details: ${e.stack || 'No stack trace'}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Response has tool_calls: ${hasToolCalls ? 'true' : 'false'}`);
|
||||
if (hasToolCalls && currentResponse.tool_calls) {
|
||||
log.info(`[TOOL CALL DEBUG] Final tool_calls that will be used: ${JSON.stringify(currentResponse.tool_calls)}`);
|
||||
}
|
||||
|
||||
// Tool execution loop
|
||||
if (toolsEnabled && hasToolCalls && currentResponse.tool_calls) {
|
||||
log.info(`========== STAGE 6: TOOL EXECUTION ==========`);
|
||||
log.info(`Response contains ${currentResponse.tool_calls.length} tool calls, processing...`);
|
||||
|
||||
// Format tool calls for logging
|
||||
log.info(`========== TOOL CALL DETAILS ==========`);
|
||||
currentResponse.tool_calls.forEach((toolCall, idx) => {
|
||||
log.info(`Tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
|
||||
log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
|
||||
});
|
||||
|
||||
// Keep track of whether we're in a streaming response
|
||||
const isStreaming = shouldEnableStream && streamCallback;
|
||||
let streamingPaused = false;
|
||||
|
||||
// If streaming was enabled, send an update to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
streamingPaused = true;
|
||||
// Send a dedicated message with a specific type for tool execution
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'start',
|
||||
tool: {
|
||||
name: 'tool_execution',
|
||||
arguments: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while (toolCallIterations < maxToolCallIterations) {
|
||||
toolCallIterations++;
|
||||
log.info(`========== TOOL ITERATION ${toolCallIterations}/${maxToolCallIterations} ==========`);
|
||||
|
||||
// Create a copy of messages before tool execution
|
||||
const previousMessages = [...currentMessages];
|
||||
|
||||
try {
|
||||
const toolCallingStartTime = Date.now();
|
||||
log.info(`========== PIPELINE TOOL EXECUTION FLOW ==========`);
|
||||
log.info(`About to call toolCalling.execute with ${currentResponse.tool_calls.length} tool calls`);
|
||||
log.info(`Tool calls being passed to stage: ${JSON.stringify(currentResponse.tool_calls)}`);
|
||||
|
||||
const toolCallingResult = await this.stages.toolCalling.execute({
|
||||
response: currentResponse,
|
||||
messages: currentMessages,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('toolCalling', toolCallingStartTime);
|
||||
|
||||
log.info(`ToolCalling stage execution complete, got result with needsFollowUp: ${toolCallingResult.needsFollowUp}`);
|
||||
|
||||
// Update messages with tool results
|
||||
currentMessages = toolCallingResult.messages;
|
||||
|
||||
// Log the tool results for debugging
|
||||
const toolResultMessages = currentMessages.filter(
|
||||
msg => msg.role === 'tool' && !previousMessages.includes(msg)
|
||||
);
|
||||
|
||||
log.info(`========== TOOL EXECUTION RESULTS ==========`);
|
||||
log.info(`Received ${toolResultMessages.length} tool results`);
|
||||
toolResultMessages.forEach((msg, idx) => {
|
||||
log.info(`Tool result ${idx + 1}: tool_call_id=${msg.tool_call_id}, content=${msg.content}`);
|
||||
log.info(`Tool result status: ${msg.content.startsWith('Error:') ? 'ERROR' : 'SUCCESS'}`);
|
||||
log.info(`Tool result for: ${this.getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '')}`);
|
||||
|
||||
// If streaming, show tool executions to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
// For each tool result, format a readable message for the user
|
||||
const toolName = this.getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '');
|
||||
|
||||
// Create a structured tool result message
|
||||
// The client will receive this structured data and can display it properly
|
||||
try {
|
||||
// Parse the result content if it's JSON
|
||||
let parsedContent = msg.content;
|
||||
try {
|
||||
// Check if the content is JSON
|
||||
if (msg.content.trim().startsWith('{') || msg.content.trim().startsWith('[')) {
|
||||
parsedContent = JSON.parse(msg.content);
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, keep the original content
|
||||
log.info(`Could not parse tool result as JSON: ${e}`);
|
||||
}
|
||||
|
||||
// Send the structured tool result directly so the client has the raw data
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'complete',
|
||||
tool: {
|
||||
name: toolName,
|
||||
arguments: {}
|
||||
},
|
||||
result: parsedContent
|
||||
}
|
||||
});
|
||||
|
||||
// No longer need to send formatted text version
|
||||
// The client should use the structured data instead
|
||||
} catch (err) {
|
||||
log.error(`Error sending structured tool result: ${err}`);
|
||||
// Use structured format here too instead of falling back to text format
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'complete',
|
||||
tool: {
|
||||
name: toolName || 'unknown',
|
||||
arguments: {}
|
||||
},
|
||||
result: msg.content
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check if we need another LLM completion for tool results
|
||||
if (toolCallingResult.needsFollowUp) {
|
||||
log.info(`========== TOOL FOLLOW-UP REQUIRED ==========`);
|
||||
log.info('Tool execution complete, sending results back to LLM');
|
||||
|
||||
// Ensure messages are properly formatted
|
||||
this.validateToolMessages(currentMessages);
|
||||
|
||||
// If streaming, show progress to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'update',
|
||||
tool: {
|
||||
name: 'tool_processing',
|
||||
arguments: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract tool execution status information for Ollama feedback
|
||||
let toolExecutionStatus;
|
||||
|
||||
if (currentResponse.provider === 'Ollama') {
|
||||
// Collect tool execution status from the tool results
|
||||
toolExecutionStatus = toolResultMessages.map(msg => {
|
||||
// Determine if this was a successful tool call
|
||||
const isError = msg.content.startsWith('Error:');
|
||||
return {
|
||||
toolCallId: msg.tool_call_id || '',
|
||||
name: msg.name || 'unknown',
|
||||
success: !isError,
|
||||
result: msg.content,
|
||||
error: isError ? msg.content.substring(7) : undefined
|
||||
};
|
||||
});
|
||||
|
||||
log.info(`Created tool execution status for Ollama: ${toolExecutionStatus.length} entries`);
|
||||
toolExecutionStatus.forEach((status, idx) => {
|
||||
log.info(`Tool status ${idx + 1}: ${status.name} - ${status.success ? 'success' : 'failed'}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a new completion with the updated messages
|
||||
const followUpStartTime = Date.now();
|
||||
|
||||
// Log messages being sent to LLM for tool follow-up
|
||||
log.info(`========== SENDING TOOL RESULTS TO LLM FOR FOLLOW-UP ==========`);
|
||||
log.info(`Total messages being sent: ${currentMessages.length}`);
|
||||
// Log the most recent messages (last 3) for clarity
|
||||
const recentMessages = currentMessages.slice(-3);
|
||||
recentMessages.forEach((msg, idx) => {
|
||||
const position = currentMessages.length - recentMessages.length + idx;
|
||||
log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`);
|
||||
if (msg.tool_calls) {
|
||||
log.info(` Has ${msg.tool_calls.length} tool calls`);
|
||||
}
|
||||
if (msg.tool_call_id) {
|
||||
log.info(` Tool call ID: ${msg.tool_call_id}`);
|
||||
}
|
||||
});
|
||||
|
||||
log.info(`LLM follow-up request options: ${JSON.stringify({
|
||||
model: modelSelection.options.model,
|
||||
enableTools: true,
|
||||
stream: modelSelection.options.stream,
|
||||
provider: currentResponse.provider
|
||||
})}`);
|
||||
|
||||
const followUpCompletion = await this.stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...modelSelection.options,
|
||||
// Ensure tool support is still enabled for follow-up requests
|
||||
enableTools: true,
|
||||
// Preserve original streaming setting for tool execution follow-ups
|
||||
stream: modelSelection.options.stream,
|
||||
// Add tool execution status for Ollama provider
|
||||
...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
|
||||
}
|
||||
});
|
||||
this.updateStageMetrics('llmCompletion', followUpStartTime);
|
||||
|
||||
// Log the follow-up response from the LLM
|
||||
log.info(`========== LLM FOLLOW-UP RESPONSE RECEIVED ==========`);
|
||||
log.info(`Follow-up response model: ${followUpCompletion.response.model}, provider: ${followUpCompletion.response.provider}`);
|
||||
log.info(`Follow-up response text: ${followUpCompletion.response.text?.substring(0, 150)}${followUpCompletion.response.text?.length > 150 ? '...' : ''}`);
|
||||
log.info(`Follow-up contains tool calls: ${!!followUpCompletion.response.tool_calls && followUpCompletion.response.tool_calls.length > 0}`);
|
||||
if (followUpCompletion.response.tool_calls && followUpCompletion.response.tool_calls.length > 0) {
|
||||
log.info(`Follow-up has ${followUpCompletion.response.tool_calls.length} new tool calls`);
|
||||
}
|
||||
|
||||
// Update current response for the next iteration
|
||||
currentResponse = followUpCompletion.response;
|
||||
|
||||
// Check if we need to continue the tool calling loop
|
||||
if (!currentResponse.tool_calls || currentResponse.tool_calls.length === 0) {
|
||||
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
|
||||
log.info('No more tool calls, breaking tool execution loop');
|
||||
break;
|
||||
} else {
|
||||
log.info(`========== ADDITIONAL TOOL CALLS DETECTED ==========`);
|
||||
log.info(`Next iteration has ${currentResponse.tool_calls.length} more tool calls`);
|
||||
// Log the next set of tool calls
|
||||
currentResponse.tool_calls.forEach((toolCall, idx) => {
|
||||
log.info(`Next tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
|
||||
log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
|
||||
log.info('No follow-up needed, breaking tool execution loop');
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.info(`========== TOOL EXECUTION ERROR ==========`);
|
||||
log.error(`Error in tool execution: ${error.message || String(error)}`);
|
||||
|
||||
// Add error message to the conversation if tool execution fails
|
||||
currentMessages.push({
|
||||
role: 'system',
|
||||
content: `Error executing tool: ${error.message || String(error)}. Please try a different approach.`
|
||||
});
|
||||
|
||||
// If streaming, show error to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'error',
|
||||
tool: {
|
||||
name: 'unknown',
|
||||
arguments: {}
|
||||
},
|
||||
result: error.message || 'unknown error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// For Ollama, create tool execution status with the error
|
||||
let toolExecutionStatus;
|
||||
if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) {
|
||||
// We need to create error statuses for all tool calls that failed
|
||||
toolExecutionStatus = currentResponse.tool_calls.map(toolCall => {
|
||||
return {
|
||||
toolCallId: toolCall.id || '',
|
||||
name: toolCall.function?.name || 'unknown',
|
||||
success: false,
|
||||
result: `Error: ${error.message || 'unknown error'}`,
|
||||
error: error.message || 'unknown error'
|
||||
};
|
||||
});
|
||||
|
||||
log.info(`Created error tool execution status for Ollama: ${toolExecutionStatus.length} entries`);
|
||||
}
|
||||
|
||||
// Make a follow-up request to the LLM with the error information
|
||||
const errorFollowUpCompletion = await this.stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...modelSelection.options,
|
||||
// Preserve streaming for error follow-up
|
||||
stream: modelSelection.options.stream,
|
||||
// For Ollama, include tool execution status
|
||||
...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
|
||||
}
|
||||
});
|
||||
|
||||
// Log the error follow-up response from the LLM
|
||||
log.info(`========== ERROR FOLLOW-UP RESPONSE RECEIVED ==========`);
|
||||
log.info(`Error follow-up response model: ${errorFollowUpCompletion.response.model}, provider: ${errorFollowUpCompletion.response.provider}`);
|
||||
log.info(`Error follow-up response text: ${errorFollowUpCompletion.response.text?.substring(0, 150)}${errorFollowUpCompletion.response.text?.length > 150 ? '...' : ''}`);
|
||||
log.info(`Error follow-up contains tool calls: ${!!errorFollowUpCompletion.response.tool_calls && errorFollowUpCompletion.response.tool_calls.length > 0}`);
|
||||
|
||||
// Update current response and break the tool loop
|
||||
currentResponse = errorFollowUpCompletion.response;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCallIterations >= maxToolCallIterations) {
|
||||
log.info(`========== MAXIMUM TOOL ITERATIONS REACHED ==========`);
|
||||
log.error(`Reached maximum tool call iterations (${maxToolCallIterations}), terminating loop`);
|
||||
|
||||
// Add a message to inform the LLM that we've reached the limit
|
||||
currentMessages.push({
|
||||
role: 'system',
|
||||
content: `Maximum tool call iterations (${maxToolCallIterations}) reached. Please provide your best response with the information gathered so far.`
|
||||
});
|
||||
|
||||
// If streaming, inform the user about iteration limit
|
||||
if (isStreaming && streamCallback) {
|
||||
streamCallback(`[Reached maximum of ${maxToolCallIterations} tool calls. Finalizing response...]\n\n`, false);
|
||||
}
|
||||
|
||||
// For Ollama, create a status about reaching max iterations
|
||||
let toolExecutionStatus;
|
||||
if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) {
|
||||
// Create a special status message about max iterations
|
||||
toolExecutionStatus = [
|
||||
{
|
||||
toolCallId: 'max-iterations',
|
||||
name: 'system',
|
||||
success: false,
|
||||
result: `Maximum tool call iterations (${maxToolCallIterations}) reached.`,
|
||||
error: `Reached the maximum number of allowed tool calls (${maxToolCallIterations}). Please provide a final response with the information gathered so far.`
|
||||
}
|
||||
];
|
||||
|
||||
log.info(`Created max iterations status for Ollama`);
|
||||
}
|
||||
|
||||
// Make a final request to get a summary response
|
||||
const finalFollowUpCompletion = await this.stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...modelSelection.options,
|
||||
enableTools: false, // Disable tools for the final response
|
||||
// Preserve streaming setting for max iterations response
|
||||
stream: modelSelection.options.stream,
|
||||
// For Ollama, include tool execution status
|
||||
...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
|
||||
}
|
||||
});
|
||||
|
||||
// Update the current response
|
||||
currentResponse = finalFollowUpCompletion.response;
|
||||
}
|
||||
|
||||
// If streaming was paused for tool execution, resume it now with the final response
|
||||
if (isStreaming && streamCallback && streamingPaused) {
|
||||
// First log for debugging
|
||||
const responseText = currentResponse.text || "";
|
||||
log.info(`Resuming streaming with final response: ${responseText.length} chars`);
|
||||
|
||||
if (responseText.length > 0 && !hasStreamedContent) {
|
||||
// Resume streaming with the final response text only if we haven't already streamed content
|
||||
// This is where we send the definitive done:true signal with the complete content
|
||||
streamCallback(responseText, true);
|
||||
log.info(`Sent final response with done=true signal and text content`);
|
||||
} else if (hasStreamedContent) {
|
||||
log.info(`Content already streamed, sending done=true signal only after tool execution`);
|
||||
// Just send the done signal without duplicating content
|
||||
streamCallback('', true);
|
||||
} else {
|
||||
// For Anthropic, sometimes text is empty but response is in stream
|
||||
if ((currentResponse.provider === 'Anthropic' || currentResponse.provider === 'OpenAI') && currentResponse.stream) {
|
||||
log.info(`Detected empty response text for ${currentResponse.provider} provider with stream, sending stream content directly`);
|
||||
// For Anthropic/OpenAI with stream mode, we need to stream the final response
|
||||
if (currentResponse.stream) {
|
||||
await currentResponse.stream(async (chunk: StreamChunk) => {
|
||||
// Process the chunk
|
||||
const processedChunk = await this.processStreamChunk(chunk, input.options);
|
||||
|
||||
// Forward to callback
|
||||
streamCallback(
|
||||
processedChunk.text,
|
||||
processedChunk.done || chunk.done || false,
|
||||
chunk
|
||||
);
|
||||
});
|
||||
log.info(`Completed streaming final ${currentResponse.provider} response after tool execution`);
|
||||
}
|
||||
} else {
|
||||
// Empty response with done=true as fallback
|
||||
streamCallback('', true);
|
||||
log.info(`Sent empty final response with done=true signal`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (toolsEnabled) {
|
||||
log.info(`========== NO TOOL CALLS DETECTED ==========`);
|
||||
log.info(`LLM response did not contain any tool calls, skipping tool execution`);
|
||||
|
||||
// Handle streaming for responses without tool calls
|
||||
if (shouldEnableStream && streamCallback && !hasStreamedContent) {
|
||||
log.info(`Sending final streaming response without tool calls: ${currentResponse.text.length} chars`);
|
||||
|
||||
// Send the final response with done=true to complete the streaming
|
||||
streamCallback(currentResponse.text, true);
|
||||
|
||||
log.info(`Sent final non-tool response with done=true signal`);
|
||||
} else if (shouldEnableStream && streamCallback && hasStreamedContent) {
|
||||
log.info(`Content already streamed, sending done=true signal only`);
|
||||
// Just send the done signal without duplicating content
|
||||
streamCallback('', true);
|
||||
}
|
||||
}
|
||||
|
||||
// Process the final response
|
||||
log.info(`========== FINAL RESPONSE PROCESSING ==========`);
|
||||
const responseProcessingStartTime = Date.now();
|
||||
const processedResponse = await this.stages.responseProcessing.execute({
|
||||
response: currentResponse,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('responseProcessing', responseProcessingStartTime);
|
||||
log.info(`Final response processed, returning to user (${processedResponse.text.length} chars)`);
|
||||
|
||||
// Return the final response to the user
|
||||
// The ResponseProcessingStage returns {text}, not {response}
|
||||
// So we update our currentResponse with the processed text
|
||||
currentResponse.text = processedResponse.text;
|
||||
|
||||
log.info(`========== PIPELINE COMPLETE ==========`);
|
||||
return currentResponse;
|
||||
} catch (error: any) {
|
||||
log.info(`========== PIPELINE ERROR ==========`);
|
||||
log.error(`Error in chat pipeline: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get an LLM service for query processing
|
||||
*/
|
||||
private async getLLMService(): Promise<LLMServiceInterface | null> {
|
||||
try {
|
||||
const aiServiceManager = await import('../ai_service_manager.js').then(module => module.default);
|
||||
return aiServiceManager.getService();
|
||||
} catch (error: any) {
|
||||
log.error(`Error getting LLM service: ${error.message || String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a stream chunk through the response processing stage
|
||||
*/
|
||||
private async processStreamChunk(chunk: StreamChunk, options?: any): Promise<StreamChunk> {
|
||||
try {
|
||||
// Only process non-empty chunks
|
||||
if (!chunk.text) return chunk;
|
||||
|
||||
// Create a minimal response object for the processor
|
||||
const miniResponse = {
|
||||
text: chunk.text,
|
||||
model: 'streaming',
|
||||
provider: 'streaming'
|
||||
};
|
||||
|
||||
// Process the chunk text
|
||||
const processed = await this.stages.responseProcessing.execute({
|
||||
response: miniResponse,
|
||||
options: options
|
||||
});
|
||||
|
||||
// Return processed chunk
|
||||
return {
|
||||
...chunk,
|
||||
text: processed.text
|
||||
};
|
||||
} catch (error) {
|
||||
// On error, return original chunk
|
||||
log.error(`Error processing stream chunk: ${error}`);
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metrics for a pipeline stage
|
||||
*/
|
||||
private updateStageMetrics(stageName: string, startTime: number) {
|
||||
if (!this.config.enableMetrics) return;
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
const metrics = this.metrics.stageMetrics[stageName];
|
||||
|
||||
// Guard against undefined metrics (e.g., for removed stages)
|
||||
if (!metrics) {
|
||||
log.info(`WARNING: Attempted to update metrics for unknown stage: ${stageName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.totalExecutions++;
|
||||
metrics.averageExecutionTime =
|
||||
(metrics.averageExecutionTime * (metrics.totalExecutions - 1) + executionTime) /
|
||||
metrics.totalExecutions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current pipeline metrics
|
||||
*/
|
||||
getMetrics(): PipelineMetrics {
|
||||
return this.metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset pipeline metrics
|
||||
*/
|
||||
resetMetrics(): void {
|
||||
this.metrics.totalExecutions = 0;
|
||||
this.metrics.averageExecutionTime = 0;
|
||||
|
||||
Object.keys(this.metrics.stageMetrics).forEach(stageName => {
|
||||
this.metrics.stageMetrics[stageName] = {
|
||||
totalExecutions: 0,
|
||||
averageExecutionTime: 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tool name from tool call ID by looking at previous assistant messages
|
||||
*/
|
||||
private getToolNameFromToolCallId(messages: Message[], toolCallId: string): string {
|
||||
if (!toolCallId) return 'unknown';
|
||||
|
||||
// Look for assistant messages with tool_calls
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message.role === 'assistant' && message.tool_calls) {
|
||||
// Find the tool call with the matching ID
|
||||
const toolCall = message.tool_calls.find(tc => tc.id === toolCallId);
|
||||
if (toolCall && toolCall.function && toolCall.function.name) {
|
||||
return toolCall.function.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tool messages to ensure they're properly formatted
|
||||
*/
|
||||
private validateToolMessages(messages: Message[]): void {
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
|
||||
// Ensure tool messages have required fields
|
||||
if (message.role === 'tool') {
|
||||
if (!message.tool_call_id) {
|
||||
log.info(`Tool message missing tool_call_id, adding placeholder`);
|
||||
message.tool_call_id = `tool_${i}`;
|
||||
}
|
||||
|
||||
// Content should be a string
|
||||
if (typeof message.content !== 'string') {
|
||||
log.info(`Tool message content is not a string, converting`);
|
||||
try {
|
||||
message.content = JSON.stringify(message.content);
|
||||
} catch (e) {
|
||||
message.content = String(message.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
import type { PipelineInput, PipelineOutput, PipelineStage } from './interfaces.js';
|
||||
import log from '../../log.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for pipeline stages
|
||||
*/
|
||||
export abstract class BasePipelineStage<TInput extends PipelineInput, TOutput extends PipelineOutput> implements PipelineStage<TInput, TOutput> {
|
||||
name: string;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the pipeline stage
|
||||
*/
|
||||
async execute(input: TInput): Promise<TOutput> {
|
||||
try {
|
||||
log.info(`Executing pipeline stage: ${this.name}`);
|
||||
const startTime = Date.now();
|
||||
const result = await this.process(input);
|
||||
const endTime = Date.now();
|
||||
log.info(`Pipeline stage ${this.name} completed in ${endTime - startTime}ms`);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
log.error(`Error in pipeline stage ${this.name}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the input and produce output
|
||||
* This is the main method that each pipeline stage must implement
|
||||
*/
|
||||
protected abstract process(input: TInput): Promise<TOutput>;
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import type { PipelineInput } from '../interfaces.js';
|
||||
import aiServiceManager from '../../ai_service_manager.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
export interface AgentToolsContextInput {
|
||||
noteId?: string;
|
||||
query?: string;
|
||||
showThinking?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentToolsContextOutput {
|
||||
context: string;
|
||||
noteId: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline stage for adding LLM agent tools context
|
||||
*/
|
||||
export class AgentToolsContextStage {
|
||||
constructor() {
|
||||
log.info('AgentToolsContextStage initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the agent tools context stage
|
||||
*/
|
||||
async execute(input: AgentToolsContextInput): Promise<AgentToolsContextOutput> {
|
||||
return this.process(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the input and add agent tools context
|
||||
*/
|
||||
protected async process(input: AgentToolsContextInput): Promise<AgentToolsContextOutput> {
|
||||
const noteId = input.noteId || 'global';
|
||||
const query = input.query || '';
|
||||
const showThinking = !!input.showThinking;
|
||||
|
||||
log.info(`AgentToolsContextStage: Getting agent tools context for noteId=${noteId}, query="${query.substring(0, 30)}...", showThinking=${showThinking}`);
|
||||
|
||||
try {
|
||||
// Use the AI service manager to get agent tools context
|
||||
const context = await aiServiceManager.getAgentToolsContext(noteId, query, showThinking);
|
||||
|
||||
log.info(`AgentToolsContextStage: Generated agent tools context (${context.length} chars)`);
|
||||
|
||||
return {
|
||||
context,
|
||||
noteId,
|
||||
query
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`AgentToolsContextStage: Error getting agent tools context: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import type { ContextExtractionInput } from '../interfaces.js';
|
||||
import aiServiceManager from '../../ai_service_manager.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
/**
|
||||
* Context Extraction Pipeline Stage
|
||||
*/
|
||||
|
||||
export interface ContextExtractionOutput {
|
||||
context: string;
|
||||
noteId: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline stage for extracting context from notes
|
||||
*/
|
||||
export class ContextExtractionStage {
|
||||
constructor() {
|
||||
log.info('ContextExtractionStage initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the context extraction stage
|
||||
*/
|
||||
async execute(input: ContextExtractionInput): Promise<ContextExtractionOutput> {
|
||||
return this.process(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the input and extract context
|
||||
*/
|
||||
protected async process(input: ContextExtractionInput): Promise<ContextExtractionOutput> {
|
||||
const { useSmartContext = true } = input;
|
||||
const noteId = input.noteId || 'global';
|
||||
const query = input.query || '';
|
||||
|
||||
log.info(`ContextExtractionStage: Extracting context for noteId=${noteId}, query="${query.substring(0, 30)}..."`);
|
||||
|
||||
try {
|
||||
let context = '';
|
||||
|
||||
// Get enhanced context from the context service
|
||||
const contextService = aiServiceManager.getContextService();
|
||||
const llmService = await aiServiceManager.getService();
|
||||
|
||||
if (contextService) {
|
||||
// Use unified context service to get smart context
|
||||
context = await contextService.processQuery(
|
||||
query,
|
||||
llmService,
|
||||
{ contextNoteId: noteId }
|
||||
).then(result => result.context);
|
||||
|
||||
log.info(`ContextExtractionStage: Generated enhanced context (${context.length} chars)`);
|
||||
} else {
|
||||
log.info('ContextExtractionStage: Context service not available, using default context');
|
||||
}
|
||||
|
||||
return {
|
||||
context,
|
||||
noteId,
|
||||
query
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`ContextExtractionStage: Error extracting context: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,566 +0,0 @@
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import type { ToolExecutionInput, StreamCallback } from '../interfaces.js';
|
||||
import type { ChatResponse, Message } from '../../ai_interface.js';
|
||||
import toolRegistry from '../../tools/tool_registry.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
interface RetryStrategy {
|
||||
maxRetries: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
backoffMultiplier: number;
|
||||
jitter: boolean;
|
||||
}
|
||||
|
||||
interface ToolRetryContext {
|
||||
toolName: string;
|
||||
attempt: number;
|
||||
lastError: string;
|
||||
alternativeApproaches: string[];
|
||||
usedApproaches: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Error Recovery Pipeline Stage
|
||||
* Implements sophisticated retry strategies with exponential backoff,
|
||||
* alternative tool selection, and intelligent fallback mechanisms
|
||||
*/
|
||||
export class ErrorRecoveryStage extends BasePipelineStage<ToolExecutionInput, { response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> {
|
||||
|
||||
private retryStrategies: Map<string, RetryStrategy> = new Map();
|
||||
private activeRetries: Map<string, ToolRetryContext> = new Map();
|
||||
|
||||
constructor() {
|
||||
super('ErrorRecovery');
|
||||
this.initializeRetryStrategies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize retry strategies for different tool types
|
||||
*/
|
||||
private initializeRetryStrategies(): void {
|
||||
// Search tools - more aggressive retries since they're critical
|
||||
this.retryStrategies.set('search_notes', {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 8000,
|
||||
backoffMultiplier: 2,
|
||||
jitter: true
|
||||
});
|
||||
|
||||
this.retryStrategies.set('keyword_search', {
|
||||
maxRetries: 3,
|
||||
baseDelay: 800,
|
||||
maxDelay: 6000,
|
||||
backoffMultiplier: 2,
|
||||
jitter: true
|
||||
});
|
||||
|
||||
// Read operations - moderate retries
|
||||
this.retryStrategies.set('read_note', {
|
||||
maxRetries: 2,
|
||||
baseDelay: 500,
|
||||
maxDelay: 3000,
|
||||
backoffMultiplier: 2,
|
||||
jitter: false
|
||||
});
|
||||
|
||||
// Attribute operations - conservative retries
|
||||
this.retryStrategies.set('attribute_search', {
|
||||
maxRetries: 2,
|
||||
baseDelay: 1200,
|
||||
maxDelay: 5000,
|
||||
backoffMultiplier: 1.8,
|
||||
jitter: true
|
||||
});
|
||||
|
||||
// Default strategy for unknown tools
|
||||
this.retryStrategies.set('default', {
|
||||
maxRetries: 2,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 4000,
|
||||
backoffMultiplier: 2,
|
||||
jitter: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tool execution with advanced error recovery
|
||||
*/
|
||||
protected async process(input: ToolExecutionInput): Promise<{ response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> {
|
||||
const { response } = input;
|
||||
|
||||
// If no tool calls, pass through
|
||||
if (!response.tool_calls || response.tool_calls.length === 0) {
|
||||
return { response, needsFollowUp: false, messages: input.messages };
|
||||
}
|
||||
|
||||
log.info(`========== ERROR RECOVERY STAGE PROCESSING ==========`);
|
||||
log.info(`Processing ${response.tool_calls.length} tool calls with advanced error recovery`);
|
||||
|
||||
const recoveredToolCalls: Array<{
|
||||
toolCall: any;
|
||||
message: Message;
|
||||
attempts: number;
|
||||
recovered: boolean;
|
||||
}> = [];
|
||||
const updatedMessages = [...input.messages];
|
||||
|
||||
// Process each tool call with recovery
|
||||
for (let i = 0; i < response.tool_calls.length; i++) {
|
||||
const toolCall = response.tool_calls[i];
|
||||
const recoveredResult = await this.executeToolWithRecovery(toolCall, input, i);
|
||||
|
||||
if (recoveredResult) {
|
||||
recoveredToolCalls.push(recoveredResult);
|
||||
updatedMessages.push(recoveredResult.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Create enhanced response with recovery information
|
||||
const enhancedResponse: ChatResponse = {
|
||||
...response,
|
||||
tool_calls: recoveredToolCalls.map(r => r.toolCall),
|
||||
recovery_metadata: {
|
||||
total_attempts: recoveredToolCalls.reduce((sum, r) => sum + r.attempts, 0),
|
||||
successful_recoveries: recoveredToolCalls.filter(r => r.recovered).length,
|
||||
failed_permanently: recoveredToolCalls.filter(r => !r.recovered).length
|
||||
}
|
||||
};
|
||||
|
||||
const needsFollowUp = recoveredToolCalls.length > 0;
|
||||
|
||||
log.info(`Recovery complete: ${recoveredToolCalls.filter(r => r.recovered).length}/${recoveredToolCalls.length} tools recovered`);
|
||||
|
||||
return {
|
||||
response: enhancedResponse,
|
||||
needsFollowUp,
|
||||
messages: updatedMessages
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool call with comprehensive error recovery
|
||||
*/
|
||||
private async executeToolWithRecovery(
|
||||
toolCall: any,
|
||||
input: ToolExecutionInput,
|
||||
index: number
|
||||
): Promise<{ toolCall: any, message: Message, attempts: number, recovered: boolean } | null> {
|
||||
|
||||
const toolName = toolCall.function.name;
|
||||
const strategy = this.retryStrategies.get(toolName) || this.retryStrategies.get('default')!;
|
||||
|
||||
let lastError = '';
|
||||
let attempts = 0;
|
||||
let recovered = false;
|
||||
|
||||
// Initialize retry context
|
||||
const retryContext: ToolRetryContext = {
|
||||
toolName,
|
||||
attempt: 0,
|
||||
lastError: '',
|
||||
alternativeApproaches: this.getAlternativeApproaches(toolName),
|
||||
usedApproaches: []
|
||||
};
|
||||
|
||||
log.info(`Starting error recovery for tool: ${toolName} (max retries: ${strategy.maxRetries})`);
|
||||
|
||||
// Primary execution attempts
|
||||
for (attempts = 1; attempts <= strategy.maxRetries + 1; attempts++) {
|
||||
try {
|
||||
retryContext.attempt = attempts;
|
||||
|
||||
// Add delay for retry attempts (not first attempt)
|
||||
if (attempts > 1) {
|
||||
const delay = this.calculateDelay(strategy, attempts - 1);
|
||||
log.info(`Retry attempt ${attempts - 1} for ${toolName} after ${delay}ms delay`);
|
||||
await this.sleep(delay);
|
||||
|
||||
// Send retry notification if streaming
|
||||
if (input.streamCallback) {
|
||||
this.sendRetryNotification(input.streamCallback, toolName, attempts - 1, strategy.maxRetries);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
const tool = toolRegistry.getTool(toolName);
|
||||
if (!tool) {
|
||||
throw new Error(`Tool not found: ${toolName}`);
|
||||
}
|
||||
|
||||
// Parse arguments
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
|
||||
// Modify arguments for retry if needed
|
||||
const modifiedArgs = this.modifyArgsForRetry(args, retryContext);
|
||||
|
||||
log.info(`Executing ${toolName} (attempt ${attempts}) with args: ${JSON.stringify(modifiedArgs)}`);
|
||||
|
||||
const result = await tool.execute(modifiedArgs);
|
||||
|
||||
// Success!
|
||||
recovered = true;
|
||||
log.info(`✓ Tool ${toolName} succeeded on attempt ${attempts}`);
|
||||
|
||||
return {
|
||||
toolCall,
|
||||
message: {
|
||||
role: 'tool',
|
||||
content: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
|
||||
name: toolName,
|
||||
tool_call_id: toolCall.id
|
||||
},
|
||||
attempts,
|
||||
recovered: true
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
retryContext.lastError = lastError;
|
||||
|
||||
log.info(`✗ Tool ${toolName} failed on attempt ${attempts}: ${lastError}`);
|
||||
|
||||
// If this was the last allowed attempt, break
|
||||
if (attempts > strategy.maxRetries) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Primary attempts failed, try alternative approaches
|
||||
log.info(`Primary attempts failed for ${toolName}, trying alternative approaches`);
|
||||
|
||||
for (const alternative of retryContext.alternativeApproaches) {
|
||||
if (retryContext.usedApproaches.includes(alternative)) {
|
||||
continue; // Skip already used approaches
|
||||
}
|
||||
|
||||
try {
|
||||
log.info(`Trying alternative approach: ${alternative} for ${toolName}`);
|
||||
retryContext.usedApproaches.push(alternative);
|
||||
|
||||
const alternativeResult = await this.executeAlternativeApproach(alternative, toolCall, retryContext);
|
||||
|
||||
if (alternativeResult) {
|
||||
log.info(`✓ Alternative approach ${alternative} succeeded for ${toolName}`);
|
||||
recovered = true;
|
||||
|
||||
return {
|
||||
toolCall,
|
||||
message: {
|
||||
role: 'tool',
|
||||
content: `ALTERNATIVE_SUCCESS: ${alternative} succeeded where ${toolName} failed. Result: ${alternativeResult}`,
|
||||
name: toolName,
|
||||
tool_call_id: toolCall.id
|
||||
},
|
||||
attempts: attempts + 1,
|
||||
recovered: true
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const altError = error instanceof Error ? error.message : String(error);
|
||||
log.info(`✗ Alternative approach ${alternative} failed: ${altError}`);
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts failed
|
||||
log.error(`All recovery attempts failed for ${toolName} after ${attempts} attempts and ${retryContext.usedApproaches.length} alternatives`);
|
||||
|
||||
// Return failure message with guidance
|
||||
const failureGuidance = this.generateFailureGuidance(toolName, lastError, retryContext);
|
||||
|
||||
return {
|
||||
toolCall,
|
||||
message: {
|
||||
role: 'tool',
|
||||
content: `RECOVERY_FAILED: Tool ${toolName} failed after ${attempts} attempts and ${retryContext.usedApproaches.length} alternative approaches. Last error: ${lastError}\n\n${failureGuidance}`,
|
||||
name: toolName,
|
||||
tool_call_id: toolCall.id
|
||||
},
|
||||
attempts,
|
||||
recovered: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry delay with exponential backoff and optional jitter
|
||||
*/
|
||||
private calculateDelay(strategy: RetryStrategy, retryNumber: number): number {
|
||||
let delay = strategy.baseDelay * Math.pow(strategy.backoffMultiplier, retryNumber - 1);
|
||||
|
||||
// Apply maximum delay limit
|
||||
delay = Math.min(delay, strategy.maxDelay);
|
||||
|
||||
// Add jitter if enabled (±25% random variation)
|
||||
if (strategy.jitter) {
|
||||
const jitterRange = delay * 0.25;
|
||||
const jitter = (Math.random() - 0.5) * 2 * jitterRange;
|
||||
delay += jitter;
|
||||
}
|
||||
|
||||
return Math.round(Math.max(delay, 100)); // Minimum 100ms delay
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alternative approaches for a tool
|
||||
*/
|
||||
private getAlternativeApproaches(toolName: string): string[] {
|
||||
const alternatives: Record<string, string[]> = {
|
||||
'search_notes': ['keyword_search', 'broader_search_terms', 'attribute_search'],
|
||||
'keyword_search': ['search_notes', 'simplified_query', 'attribute_search'],
|
||||
'attribute_search': ['search_notes', 'keyword_search', 'different_attribute_type'],
|
||||
'read_note': ['note_by_path', 'search_and_read', 'template_search'],
|
||||
'note_by_path': ['read_note', 'search_notes', 'keyword_search']
|
||||
};
|
||||
|
||||
return alternatives[toolName] || ['search_notes', 'keyword_search'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify arguments for retry attempts
|
||||
*/
|
||||
private modifyArgsForRetry(args: Record<string, unknown>, context: ToolRetryContext): Record<string, unknown> {
|
||||
const modified = { ...args };
|
||||
|
||||
// For search tools, broaden the query on retries
|
||||
if (context.toolName.includes('search') && context.attempt > 1) {
|
||||
if (modified.query && typeof modified.query === 'string') {
|
||||
// Remove quotes and qualifiers to broaden the search
|
||||
modified.query = (modified.query as string)
|
||||
.replace(/['"]/g, '') // Remove quotes
|
||||
.replace(/\b(exactly|specific|precise)\b/gi, '') // Remove limiting words
|
||||
.trim();
|
||||
|
||||
log.info(`Modified query for retry: "${modified.query}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// For attribute search, try different attribute types
|
||||
if (context.toolName === 'attribute_search' && context.attempt > 1) {
|
||||
if (modified.attributeType === 'label') {
|
||||
modified.attributeType = 'relation';
|
||||
} else if (modified.attributeType === 'relation') {
|
||||
modified.attributeType = 'label';
|
||||
}
|
||||
|
||||
log.info(`Modified attributeType for retry: ${modified.attributeType}`);
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute alternative approach
|
||||
*/
|
||||
private async executeAlternativeApproach(
|
||||
approach: string,
|
||||
originalToolCall: any,
|
||||
context: ToolRetryContext
|
||||
): Promise<string | null> {
|
||||
|
||||
switch (approach) {
|
||||
case 'broader_search_terms':
|
||||
return await this.executeBroaderSearch(originalToolCall);
|
||||
|
||||
case 'simplified_query':
|
||||
return await this.executeSimplifiedSearch(originalToolCall);
|
||||
|
||||
case 'different_attribute_type':
|
||||
return await this.executeDifferentAttributeSearch(originalToolCall);
|
||||
|
||||
case 'search_and_read':
|
||||
return await this.executeSearchAndRead(originalToolCall);
|
||||
|
||||
default:
|
||||
// Try to execute the alternative tool directly
|
||||
return await this.executeAlternativeTool(approach, originalToolCall);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute broader search approach
|
||||
*/
|
||||
private async executeBroaderSearch(toolCall: any): Promise<string | null> {
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
|
||||
if (args.query && typeof args.query === 'string') {
|
||||
// Extract the main keywords and search more broadly
|
||||
const keywords = (args.query as string)
|
||||
.split(' ')
|
||||
.filter(word => word.length > 3)
|
||||
.slice(0, 3) // Take only first 3 main keywords
|
||||
.join(' ');
|
||||
|
||||
const broadArgs = { ...args, query: keywords };
|
||||
|
||||
const tool = toolRegistry.getTool('search_notes');
|
||||
if (tool) {
|
||||
const result = await tool.execute(broadArgs);
|
||||
return typeof result === 'string' ? result : JSON.stringify(result);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute simplified search approach
|
||||
*/
|
||||
private async executeSimplifiedSearch(toolCall: any): Promise<string | null> {
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
|
||||
if (args.query && typeof args.query === 'string') {
|
||||
// Use only the first word as a very simple search
|
||||
const firstWord = (args.query as string).split(' ')[0];
|
||||
const simpleArgs = { ...args, query: firstWord };
|
||||
|
||||
const tool = toolRegistry.getTool('keyword_search');
|
||||
if (tool) {
|
||||
const result = await tool.execute(simpleArgs);
|
||||
return typeof result === 'string' ? result : JSON.stringify(result);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute different attribute search
|
||||
*/
|
||||
private async executeDifferentAttributeSearch(toolCall: any): Promise<string | null> {
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
|
||||
if (args.attributeType) {
|
||||
const newType = args.attributeType === 'label' ? 'relation' : 'label';
|
||||
const newArgs = { ...args, attributeType: newType };
|
||||
|
||||
const tool = toolRegistry.getTool('attribute_search');
|
||||
if (tool) {
|
||||
const result = await tool.execute(newArgs);
|
||||
return typeof result === 'string' ? result : JSON.stringify(result);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute search and read approach
|
||||
*/
|
||||
private async executeSearchAndRead(toolCall: any): Promise<string | null> {
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
|
||||
// First search for notes
|
||||
const searchTool = toolRegistry.getTool('search_notes');
|
||||
if (searchTool && args.query) {
|
||||
try {
|
||||
const searchResult = await searchTool.execute({ query: args.query });
|
||||
|
||||
// Try to extract note IDs and read the first one
|
||||
const searchText = typeof searchResult === 'string' ? searchResult : JSON.stringify(searchResult);
|
||||
const noteIdMatch = searchText.match(/note[:\s]+([a-zA-Z0-9]+)/i);
|
||||
|
||||
if (noteIdMatch && noteIdMatch[1]) {
|
||||
const readTool = toolRegistry.getTool('read_note');
|
||||
if (readTool) {
|
||||
const readResult = await readTool.execute({ noteId: noteIdMatch[1] });
|
||||
return `SEARCH_AND_READ: Found and read note ${noteIdMatch[1]}. Content: ${readResult}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `SEARCH_ONLY: ${searchText}`;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute alternative tool
|
||||
*/
|
||||
private async executeAlternativeTool(toolName: string, originalToolCall: any): Promise<string | null> {
|
||||
const tool = toolRegistry.getTool(toolName);
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const args = this.parseToolArguments(originalToolCall.function.arguments);
|
||||
|
||||
try {
|
||||
const result = await tool.execute(args);
|
||||
return typeof result === 'string' ? result : JSON.stringify(result);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool arguments safely
|
||||
*/
|
||||
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
return JSON.parse(args);
|
||||
} catch {
|
||||
return { query: args };
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send retry notification via streaming
|
||||
*/
|
||||
private sendRetryNotification(
|
||||
streamCallback: StreamCallback,
|
||||
toolName: string,
|
||||
retryNumber: number,
|
||||
maxRetries: number
|
||||
): void {
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'retry',
|
||||
action: 'retry',
|
||||
tool: { name: toolName, arguments: {} },
|
||||
progress: {
|
||||
current: retryNumber,
|
||||
total: maxRetries,
|
||||
status: 'retrying',
|
||||
message: `Retrying ${toolName} (attempt ${retryNumber}/${maxRetries})...`
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate failure guidance
|
||||
*/
|
||||
private generateFailureGuidance(toolName: string, lastError: string, context: ToolRetryContext): string {
|
||||
const guidance = [
|
||||
`RECOVERY ANALYSIS for ${toolName}:`,
|
||||
`- Primary attempts: ${context.attempt}`,
|
||||
`- Alternative approaches tried: ${context.usedApproaches.join(', ') || 'none'}`,
|
||||
`- Last error: ${lastError}`,
|
||||
'',
|
||||
'SUGGESTED NEXT STEPS:',
|
||||
'- Try manual search with broader terms',
|
||||
'- Check if the requested information exists',
|
||||
'- Use discover_tools to find alternative tools',
|
||||
'- Reformulate the query with different keywords'
|
||||
];
|
||||
|
||||
return guidance.join('\n');
|
||||
}
|
||||
}
|
||||
@ -1,218 +0,0 @@
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import type { LLMCompletionInput } from '../interfaces.js';
|
||||
import type { ChatCompletionOptions, ChatResponse, StreamChunk } from '../../ai_interface.js';
|
||||
import aiServiceManager from '../../ai_service_manager.js';
|
||||
import toolRegistry from '../../tools/tool_registry.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
/**
|
||||
* Pipeline stage for LLM completion with enhanced streaming support
|
||||
*/
|
||||
export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, { response: ChatResponse }> {
|
||||
constructor() {
|
||||
super('LLMCompletion');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate LLM completion using the AI service
|
||||
*
|
||||
* This enhanced version supports better streaming by forwarding raw provider data
|
||||
* and ensuring consistent handling of stream options.
|
||||
*/
|
||||
protected async process(input: LLMCompletionInput): Promise<{ response: ChatResponse }> {
|
||||
const { messages, options } = input;
|
||||
|
||||
// Add detailed logging about the input messages, particularly useful for tool follow-ups
|
||||
log.info(`========== LLM COMPLETION STAGE - INPUT MESSAGES ==========`);
|
||||
log.info(`Total input messages: ${messages.length}`);
|
||||
|
||||
// Log if tool messages are present (used for follow-ups)
|
||||
const toolMessages = messages.filter(m => m.role === 'tool');
|
||||
if (toolMessages.length > 0) {
|
||||
log.info(`Contains ${toolMessages.length} tool result messages - likely a tool follow-up request`);
|
||||
}
|
||||
|
||||
// Log the last few messages to understand conversation context
|
||||
const lastMessages = messages.slice(-3);
|
||||
lastMessages.forEach((msg, idx) => {
|
||||
const msgPosition = messages.length - lastMessages.length + idx;
|
||||
log.info(`Message ${msgPosition} (${msg.role}): ${msg.content?.substring(0, 150)}${msg.content?.length > 150 ? '...' : ''}`);
|
||||
if (msg.tool_calls) {
|
||||
log.info(` Contains ${msg.tool_calls.length} tool calls`);
|
||||
}
|
||||
if (msg.tool_call_id) {
|
||||
log.info(` Tool call ID: ${msg.tool_call_id}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Log completion options
|
||||
log.info(`LLM completion options: ${JSON.stringify({
|
||||
model: options.model || 'default',
|
||||
temperature: options.temperature,
|
||||
enableTools: options.enableTools,
|
||||
stream: options.stream,
|
||||
hasToolExecutionStatus: !!options.toolExecutionStatus
|
||||
})}`);
|
||||
|
||||
// Create a deep copy of options to avoid modifying the original
|
||||
const updatedOptions: ChatCompletionOptions = JSON.parse(JSON.stringify(options));
|
||||
|
||||
// Handle stream option explicitly
|
||||
if (options.stream !== undefined) {
|
||||
updatedOptions.stream = options.stream === true;
|
||||
log.info(`[LLMCompletionStage] Stream explicitly set to: ${updatedOptions.stream}`);
|
||||
}
|
||||
|
||||
// Add capture of raw provider data for streaming
|
||||
if (updatedOptions.stream) {
|
||||
// Add a function to capture raw provider data in stream chunks
|
||||
const originalStreamCallback = updatedOptions.streamCallback;
|
||||
updatedOptions.streamCallback = async (text, done, rawProviderData) => {
|
||||
// Create an enhanced chunk with the raw provider data
|
||||
const enhancedChunk = {
|
||||
text,
|
||||
done,
|
||||
// Include raw provider data if available
|
||||
raw: rawProviderData
|
||||
};
|
||||
|
||||
// Call the original callback if provided
|
||||
if (originalStreamCallback) {
|
||||
return originalStreamCallback(text, done, enhancedChunk);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Check if tools should be enabled
|
||||
if (updatedOptions.enableTools !== false) {
|
||||
const toolDefinitions = toolRegistry.getAllToolDefinitions();
|
||||
if (toolDefinitions.length > 0) {
|
||||
updatedOptions.enableTools = true;
|
||||
updatedOptions.tools = toolDefinitions;
|
||||
log.info(`========== ADDING TOOLS TO LLM REQUEST ==========`);
|
||||
log.info(`Tool count: ${toolDefinitions.length}`);
|
||||
log.info(`Tool names: ${toolDefinitions.map(t => t.function.name).join(', ')}`);
|
||||
log.info(`enableTools option: ${updatedOptions.enableTools}`);
|
||||
log.info(`===============================================`);
|
||||
} else {
|
||||
log.error(`========== NO TOOLS AVAILABLE FOR LLM ==========`);
|
||||
log.error(`Tool registry returned 0 definitions`);
|
||||
log.error(`This means the LLM will NOT have access to tools`);
|
||||
log.error(`Check tool initialization and registration`);
|
||||
log.error(`==============================================`);
|
||||
}
|
||||
} else {
|
||||
log.info(`Tools explicitly disabled (enableTools: ${updatedOptions.enableTools})`);
|
||||
}
|
||||
|
||||
// Determine which provider to use
|
||||
let selectedProvider = '';
|
||||
if (updatedOptions.providerMetadata?.provider) {
|
||||
selectedProvider = updatedOptions.providerMetadata.provider;
|
||||
log.info(`Using provider ${selectedProvider} from metadata for model ${updatedOptions.model}`);
|
||||
}
|
||||
|
||||
log.info(`Generating LLM completion, provider: ${selectedProvider || 'auto'}, model: ${updatedOptions?.model || 'default'}`);
|
||||
|
||||
// Use specific provider if available
|
||||
if (selectedProvider && aiServiceManager.isProviderAvailable(selectedProvider)) {
|
||||
const service = await aiServiceManager.getService(selectedProvider);
|
||||
log.info(`[LLMCompletionStage] Using specific service for ${selectedProvider}`);
|
||||
|
||||
// Generate completion and wrap with enhanced stream handling
|
||||
const response = await service.generateChatCompletion(messages, updatedOptions);
|
||||
|
||||
// If streaming is enabled, enhance the stream method
|
||||
if (response.stream && typeof response.stream === 'function' && updatedOptions.stream) {
|
||||
const originalStream = response.stream;
|
||||
|
||||
// Replace the stream method with an enhanced version that captures and forwards raw data
|
||||
response.stream = async (callback) => {
|
||||
return originalStream(async (chunk) => {
|
||||
// Forward the chunk with any additional provider-specific data
|
||||
// Create an enhanced chunk with provider info
|
||||
const enhancedChunk: StreamChunk = {
|
||||
...chunk,
|
||||
// If the provider didn't include raw data, add minimal info
|
||||
raw: chunk.raw || {
|
||||
provider: selectedProvider,
|
||||
model: response.model
|
||||
}
|
||||
};
|
||||
return callback(enhancedChunk);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Add enhanced logging for debugging tool execution follow-ups
|
||||
if (toolMessages.length > 0) {
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
log.info(`Response contains ${response.tool_calls.length} tool calls`);
|
||||
response.tool_calls.forEach((toolCall: any, idx: number) => {
|
||||
log.info(`Tool call ${idx + 1}: ${toolCall.function?.name || 'unnamed'}`);
|
||||
const args = typeof toolCall.function?.arguments === 'string'
|
||||
? toolCall.function?.arguments
|
||||
: JSON.stringify(toolCall.function?.arguments);
|
||||
log.info(`Arguments: ${args?.substring(0, 100) || '{}'}`);
|
||||
});
|
||||
} else {
|
||||
log.info(`Response contains no tool calls - plain text response`);
|
||||
}
|
||||
|
||||
if (toolMessages.length > 0 && !response.tool_calls) {
|
||||
log.info(`This appears to be a final response after tool execution (no new tool calls)`);
|
||||
} else if (toolMessages.length > 0 && response.tool_calls && response.tool_calls.length > 0) {
|
||||
log.info(`This appears to be a continued tool execution flow (tools followed by more tools)`);
|
||||
}
|
||||
}
|
||||
|
||||
return { response };
|
||||
}
|
||||
|
||||
// Use auto-selection if no specific provider
|
||||
log.info(`[LLMCompletionStage] Using auto-selected service`);
|
||||
const response = await aiServiceManager.generateChatCompletion(messages, updatedOptions);
|
||||
|
||||
// Add similar stream enhancement for auto-selected provider
|
||||
if (response.stream && typeof response.stream === 'function' && updatedOptions.stream) {
|
||||
const originalStream = response.stream;
|
||||
response.stream = async (callback) => {
|
||||
return originalStream(async (chunk) => {
|
||||
// Create an enhanced chunk with provider info
|
||||
const enhancedChunk: StreamChunk = {
|
||||
...chunk,
|
||||
raw: chunk.raw || {
|
||||
provider: response.provider,
|
||||
model: response.model
|
||||
}
|
||||
};
|
||||
return callback(enhancedChunk);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Add enhanced logging for debugging tool execution follow-ups
|
||||
if (toolMessages.length > 0) {
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
log.info(`Response contains ${response.tool_calls.length} tool calls`);
|
||||
response.tool_calls.forEach((toolCall: any, idx: number) => {
|
||||
log.info(`Tool call ${idx + 1}: ${toolCall.function?.name || 'unnamed'}`);
|
||||
const args = typeof toolCall.function?.arguments === 'string'
|
||||
? toolCall.function?.arguments
|
||||
: JSON.stringify(toolCall.function?.arguments);
|
||||
log.info(`Arguments: ${args?.substring(0, 100) || '{}'}`);
|
||||
});
|
||||
} else {
|
||||
log.info(`Response contains no tool calls - plain text response`);
|
||||
}
|
||||
|
||||
if (toolMessages.length > 0 && !response.tool_calls) {
|
||||
log.info(`This appears to be a final response after tool execution (no new tool calls)`);
|
||||
} else if (toolMessages.length > 0 && response.tool_calls && response.tool_calls.length > 0) {
|
||||
log.info(`This appears to be a continued tool execution flow (tools followed by more tools)`);
|
||||
}
|
||||
}
|
||||
|
||||
return { response };
|
||||
}
|
||||
}
|
||||
@ -1,244 +0,0 @@
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import type { MessagePreparationInput } from '../interfaces.js';
|
||||
import type { Message } from '../../ai_interface.js';
|
||||
import { SYSTEM_PROMPTS } from '../../constants/llm_prompt_constants.js';
|
||||
import { MessageFormatterFactory } from '../interfaces/message_formatter.js';
|
||||
import toolRegistry from '../../tools/tool_registry.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
/**
|
||||
* Pipeline stage for preparing messages for LLM completion
|
||||
*/
|
||||
export class MessagePreparationStage extends BasePipelineStage<MessagePreparationInput, { messages: Message[] }> {
|
||||
constructor() {
|
||||
super('MessagePreparation');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare messages for LLM completion, including system prompt and context
|
||||
* This uses provider-specific formatters to optimize the message structure
|
||||
*/
|
||||
protected async process(input: MessagePreparationInput): Promise<{ messages: Message[] }> {
|
||||
const { messages, context, systemPrompt, options } = input;
|
||||
|
||||
// Determine provider from model string if available (format: "provider:model")
|
||||
let provider = 'default';
|
||||
if (options?.model && options.model.includes(':')) {
|
||||
const [providerName] = options.model.split(':');
|
||||
provider = providerName;
|
||||
}
|
||||
|
||||
// Check if tools are enabled
|
||||
const toolsEnabled = options?.enableTools === true;
|
||||
|
||||
log.info(`Preparing messages for provider: ${provider}, context: ${!!context}, system prompt: ${!!systemPrompt}, tools: ${toolsEnabled}`);
|
||||
log.info(`Input message count: ${messages.length}`);
|
||||
|
||||
// Apply intelligent context management for long conversations
|
||||
const managedMessages = await this.applyContextManagement(messages, provider, options);
|
||||
log.info(`After context management: ${managedMessages.length} messages (reduced by ${messages.length - managedMessages.length})`);
|
||||
|
||||
// Get appropriate formatter for this provider
|
||||
const formatter = MessageFormatterFactory.getFormatter(provider);
|
||||
|
||||
// Determine the system prompt to use
|
||||
let finalSystemPrompt = systemPrompt || SYSTEM_PROMPTS.DEFAULT_SYSTEM_PROMPT;
|
||||
|
||||
// If tools are enabled, enhance system prompt with tools guidance
|
||||
if (toolsEnabled) {
|
||||
const toolCount = toolRegistry.getAllTools().length;
|
||||
const toolsPrompt = `You have access to ${toolCount} tools to help you respond.
|
||||
|
||||
CRITICAL: You are designed for CONTINUOUS TOOL USAGE and ITERATIVE INVESTIGATION. When you receive tool results, this is NOT the end of your analysis - it's the beginning of deeper investigation.
|
||||
|
||||
MANDATORY APPROACH:
|
||||
- After ANY tool execution, immediately analyze the results and plan follow-up actions
|
||||
- Use multiple tools in sequence to build comprehensive responses
|
||||
- Chain tools together systematically - use results from one tool to inform the next
|
||||
- When you find partial information, immediately search for additional details
|
||||
- Cross-reference findings with alternative search approaches
|
||||
- Never stop after a single tool unless you have completely fulfilled the request
|
||||
|
||||
TOOL CHAINING EXAMPLES:
|
||||
- If search_notes finds relevant note IDs → immediately use read_note to get full content
|
||||
- If initial search returns partial results → try broader terms or alternative keywords
|
||||
- If one search tool fails → immediately try a different search tool
|
||||
- Use the information from each tool to inform better parameters for subsequent tools
|
||||
|
||||
Remember: Tool usage should be continuous and iterative until you have thoroughly investigated the user's request.`;
|
||||
|
||||
// Add tools guidance to system prompt
|
||||
finalSystemPrompt = finalSystemPrompt + '\n\n' + toolsPrompt;
|
||||
log.info(`Enhanced system prompt with aggressive tool chaining guidance: ${toolCount} tools available`);
|
||||
}
|
||||
|
||||
// Format messages using provider-specific approach
|
||||
const formattedMessages = formatter.formatMessages(
|
||||
managedMessages,
|
||||
finalSystemPrompt,
|
||||
context
|
||||
);
|
||||
|
||||
log.info(`Formatted ${managedMessages.length} messages into ${formattedMessages.length} messages for provider: ${provider}`);
|
||||
|
||||
return { messages: formattedMessages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply intelligent context management to handle long conversations
|
||||
* Implements various strategies like sliding window, summarization, and importance-based pruning
|
||||
*/
|
||||
private async applyContextManagement(messages: Message[], provider: string, options?: any): Promise<Message[]> {
|
||||
const maxMessages = this.getMaxMessagesForProvider(provider);
|
||||
|
||||
// If we're under the limit, return as-is
|
||||
if (messages.length <= maxMessages) {
|
||||
log.info(`Message count (${messages.length}) within limit (${maxMessages}), no context management needed`);
|
||||
return messages;
|
||||
}
|
||||
|
||||
log.info(`Message count (${messages.length}) exceeds limit (${maxMessages}), applying context management`);
|
||||
|
||||
// Strategy 1: Preserve recent messages and important system/tool messages
|
||||
const managedMessages = await this.applySlidingWindowWithImportanceFiltering(messages, maxMessages);
|
||||
|
||||
// Strategy 2: If still too many, apply summarization to older messages
|
||||
if (managedMessages.length > maxMessages) {
|
||||
return await this.applySummarizationToOlderMessages(managedMessages, maxMessages);
|
||||
}
|
||||
|
||||
return managedMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum message count for different providers based on their context windows
|
||||
*/
|
||||
private getMaxMessagesForProvider(provider: string): number {
|
||||
const limits = {
|
||||
'anthropic': 50, // Conservative for Claude's context window management
|
||||
'openai': 40, // Conservative for GPT models
|
||||
'ollama': 30, // More conservative for local models
|
||||
'default': 35 // Safe default
|
||||
};
|
||||
|
||||
return limits[provider as keyof typeof limits] || limits.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply sliding window with importance filtering
|
||||
* Keeps recent messages and important system/tool messages
|
||||
*/
|
||||
private async applySlidingWindowWithImportanceFiltering(messages: Message[], maxMessages: number): Promise<Message[]> {
|
||||
if (messages.length <= maxMessages) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Always preserve the first system message if it exists
|
||||
const systemMessages = messages.filter(msg => msg.role === 'system').slice(0, 1);
|
||||
|
||||
// Find tool-related messages that are important to preserve
|
||||
const toolMessages = messages.filter(msg =>
|
||||
msg.role === 'tool' ||
|
||||
(msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0)
|
||||
);
|
||||
|
||||
// Calculate how many recent messages we can keep
|
||||
const preservedCount = systemMessages.length;
|
||||
const recentMessageCount = Math.min(maxMessages - preservedCount, messages.length);
|
||||
|
||||
// Get the most recent messages
|
||||
const recentMessages = messages.slice(-recentMessageCount);
|
||||
|
||||
// Combine system messages + recent messages, avoiding duplicates
|
||||
const result: Message[] = [];
|
||||
|
||||
// Add system messages first
|
||||
systemMessages.forEach(msg => {
|
||||
if (!result.some(existing => existing === msg)) {
|
||||
result.push(msg);
|
||||
}
|
||||
});
|
||||
|
||||
// Add recent messages
|
||||
recentMessages.forEach(msg => {
|
||||
if (!result.some(existing => existing === msg)) {
|
||||
result.push(msg);
|
||||
}
|
||||
});
|
||||
|
||||
log.info(`Sliding window filtering: preserved ${preservedCount} system messages, kept ${recentMessages.length} recent messages`);
|
||||
|
||||
return result.slice(0, maxMessages); // Ensure we don't exceed the limit
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply summarization to older messages when needed
|
||||
* Summarizes conversation segments to reduce token count while preserving context
|
||||
*/
|
||||
private async applySummarizationToOlderMessages(messages: Message[], maxMessages: number): Promise<Message[]> {
|
||||
if (messages.length <= maxMessages) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Keep recent messages (last 60% of limit)
|
||||
const recentCount = Math.floor(maxMessages * 0.6);
|
||||
const recentMessages = messages.slice(-recentCount);
|
||||
|
||||
// Get older messages to summarize
|
||||
const olderMessages = messages.slice(0, messages.length - recentCount);
|
||||
|
||||
// Create a summary of older messages
|
||||
const summary = this.createConversationSummary(olderMessages);
|
||||
|
||||
// Create a summary message
|
||||
const summaryMessage: Message = {
|
||||
role: 'system',
|
||||
content: `CONVERSATION SUMMARY: Previous conversation included ${olderMessages.length} messages. Key points: ${summary}`
|
||||
};
|
||||
|
||||
log.info(`Applied summarization: summarized ${olderMessages.length} older messages, kept ${recentMessages.length} recent messages`);
|
||||
|
||||
return [summaryMessage, ...recentMessages];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a concise summary of conversation messages
|
||||
*/
|
||||
private createConversationSummary(messages: Message[]): string {
|
||||
const userQueries: string[] = [];
|
||||
const assistantActions: string[] = [];
|
||||
const toolUsage: string[] = [];
|
||||
|
||||
messages.forEach(msg => {
|
||||
if (msg.role === 'user') {
|
||||
// Extract key topics from user messages
|
||||
const content = msg.content?.substring(0, 100) || '';
|
||||
if (content.trim()) {
|
||||
userQueries.push(content.trim());
|
||||
}
|
||||
} else if (msg.role === 'assistant') {
|
||||
// Track tool usage
|
||||
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
msg.tool_calls.forEach(tool => {
|
||||
if (tool.function?.name) {
|
||||
toolUsage.push(tool.function.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const summary: string[] = [];
|
||||
|
||||
if (userQueries.length > 0) {
|
||||
summary.push(`User asked about: ${userQueries.slice(0, 3).join(', ')}`);
|
||||
}
|
||||
|
||||
if (toolUsage.length > 0) {
|
||||
const uniqueTools = [...new Set(toolUsage)];
|
||||
summary.push(`Tools used: ${uniqueTools.slice(0, 5).join(', ')}`);
|
||||
}
|
||||
|
||||
return summary.join('. ') || 'General conversation about notes and information retrieval';
|
||||
}
|
||||
}
|
||||
@ -1,229 +0,0 @@
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import type { ModelSelectionInput } from '../interfaces.js';
|
||||
import type { ChatCompletionOptions } from '../../ai_interface.js';
|
||||
import type { ModelMetadata } from '../../providers/provider_options.js';
|
||||
import log from '../../../log.js';
|
||||
import aiServiceManager from '../../ai_service_manager.js';
|
||||
import { SEARCH_CONSTANTS, MODEL_CAPABILITIES } from "../../constants/search_constants.js";
|
||||
|
||||
// Import types
|
||||
import type { ServiceProviders } from '../../interfaces/ai_service_interfaces.js';
|
||||
|
||||
// Import new configuration system
|
||||
import {
|
||||
getSelectedProvider,
|
||||
parseModelIdentifier,
|
||||
getDefaultModelForProvider,
|
||||
createModelConfig
|
||||
} from '../../config/configuration_helpers.js';
|
||||
import type { ProviderType } from '../../interfaces/configuration_interfaces.js';
|
||||
|
||||
/**
|
||||
* Pipeline stage for selecting the appropriate LLM model
|
||||
*/
|
||||
export class ModelSelectionStage extends BasePipelineStage<ModelSelectionInput, { options: ChatCompletionOptions }> {
|
||||
constructor() {
|
||||
super('ModelSelection');
|
||||
}
|
||||
/**
|
||||
* Select the appropriate model based on input complexity
|
||||
*/
|
||||
protected async process(input: ModelSelectionInput): Promise<{ options: ChatCompletionOptions }> {
|
||||
const { options: inputOptions, query, contentLength } = input;
|
||||
|
||||
// Log input options
|
||||
log.info(`[ModelSelectionStage] Input options: ${JSON.stringify({
|
||||
model: inputOptions?.model,
|
||||
stream: inputOptions?.stream,
|
||||
enableTools: inputOptions?.enableTools
|
||||
})}`);
|
||||
log.info(`[ModelSelectionStage] Stream option in input: ${inputOptions?.stream}, type: ${typeof inputOptions?.stream}`);
|
||||
|
||||
// Start with provided options or create a new object
|
||||
const updatedOptions: ChatCompletionOptions = { ...(inputOptions || {}) };
|
||||
|
||||
// Preserve the stream option exactly as it was provided, including undefined state
|
||||
// This is critical for ensuring the stream option propagates correctly down the pipeline
|
||||
log.info(`[ModelSelectionStage] After copy, stream: ${updatedOptions.stream}, type: ${typeof updatedOptions.stream}`);
|
||||
|
||||
// If model already specified, don't override it
|
||||
if (updatedOptions.model) {
|
||||
// Use the new configuration system to parse model identifier
|
||||
const modelIdentifier = parseModelIdentifier(updatedOptions.model);
|
||||
|
||||
if (modelIdentifier.provider) {
|
||||
// Add provider metadata for backward compatibility
|
||||
this.addProviderMetadata(updatedOptions, modelIdentifier.provider as ServiceProviders, modelIdentifier.modelId);
|
||||
// Update the model to be just the model name without provider prefix
|
||||
updatedOptions.model = modelIdentifier.modelId;
|
||||
log.info(`Using explicitly specified model: ${modelIdentifier.modelId} from provider: ${modelIdentifier.provider}`);
|
||||
} else {
|
||||
log.info(`Using explicitly specified model: ${updatedOptions.model}`);
|
||||
}
|
||||
|
||||
log.info(`[ModelSelectionStage] Returning early with stream: ${updatedOptions.stream}`);
|
||||
return { options: updatedOptions };
|
||||
}
|
||||
|
||||
// Enable tools by default unless explicitly disabled
|
||||
updatedOptions.enableTools = updatedOptions.enableTools !== false;
|
||||
|
||||
// Add tools if not already provided
|
||||
if (updatedOptions.enableTools && (!updatedOptions.tools || updatedOptions.tools.length === 0)) {
|
||||
try {
|
||||
// Import tool registry and fetch tool definitions
|
||||
const toolRegistry = (await import('../../tools/tool_registry.js')).default;
|
||||
const toolDefinitions = toolRegistry.getAllToolDefinitions();
|
||||
|
||||
if (toolDefinitions.length > 0) {
|
||||
updatedOptions.tools = toolDefinitions;
|
||||
log.info(`Added ${toolDefinitions.length} tools to options`);
|
||||
} else {
|
||||
// Try to initialize tools
|
||||
log.info('No tools found in registry, trying to initialize them');
|
||||
try {
|
||||
// Tools are already initialized in the AIServiceManager constructor
|
||||
// No need to initialize them again
|
||||
|
||||
// Try again after initialization
|
||||
const reinitToolDefinitions = toolRegistry.getAllToolDefinitions();
|
||||
updatedOptions.tools = reinitToolDefinitions;
|
||||
log.info(`After initialization, added ${reinitToolDefinitions.length} tools to options`);
|
||||
} catch (initError: any) {
|
||||
log.error(`Failed to initialize tools: ${initError.message}`);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`Error loading tools: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get selected provider and model using the new configuration system
|
||||
try {
|
||||
// Use the configuration helpers to get a validated model config
|
||||
const selectedProvider = await getSelectedProvider();
|
||||
|
||||
if (!selectedProvider) {
|
||||
throw new Error('No AI provider is selected. Please select a provider in your AI settings.');
|
||||
}
|
||||
|
||||
// First try to get a valid model config (this checks both selection and configuration)
|
||||
const { getValidModelConfig } = await import('../../config/configuration_helpers.js');
|
||||
const modelConfig = await getValidModelConfig(selectedProvider);
|
||||
|
||||
if (!modelConfig) {
|
||||
throw new Error(`No default model configured for provider ${selectedProvider}. Please set a default model in your AI settings.`);
|
||||
}
|
||||
|
||||
// Use the configured model
|
||||
updatedOptions.model = modelConfig.model;
|
||||
|
||||
log.info(`Selected provider: ${selectedProvider}, model: ${updatedOptions.model}`);
|
||||
|
||||
// Determine query complexity
|
||||
let queryComplexity = 'low';
|
||||
if (query) {
|
||||
// Simple heuristic: longer queries or those with complex terms indicate higher complexity
|
||||
const complexityIndicators = [
|
||||
'explain', 'analyze', 'compare', 'evaluate', 'synthesize',
|
||||
'summarize', 'elaborate', 'investigate', 'research', 'debate'
|
||||
];
|
||||
|
||||
const hasComplexTerms = complexityIndicators.some(term => query.toLowerCase().includes(term));
|
||||
const isLongQuery = query.length > 100;
|
||||
const hasMultipleQuestions = (query.match(/\?/g) || []).length > 1;
|
||||
|
||||
if ((hasComplexTerms && isLongQuery) || hasMultipleQuestions) {
|
||||
queryComplexity = 'high';
|
||||
} else if (hasComplexTerms || isLongQuery) {
|
||||
queryComplexity = 'medium';
|
||||
}
|
||||
}
|
||||
|
||||
// Check content length if provided
|
||||
if (contentLength && contentLength > SEARCH_CONSTANTS.CONTEXT.CONTENT_LENGTH.MEDIUM_THRESHOLD) {
|
||||
// For large content, favor more powerful models
|
||||
queryComplexity = contentLength > SEARCH_CONSTANTS.CONTEXT.CONTENT_LENGTH.HIGH_THRESHOLD ? 'high' : 'medium';
|
||||
}
|
||||
|
||||
// Add provider metadata (model is already set above)
|
||||
this.addProviderMetadata(updatedOptions, selectedProvider as ServiceProviders, updatedOptions.model);
|
||||
|
||||
log.info(`Selected model: ${updatedOptions.model} from provider: ${selectedProvider} for query complexity: ${queryComplexity}`);
|
||||
log.info(`[ModelSelectionStage] Final options: ${JSON.stringify({
|
||||
model: updatedOptions.model,
|
||||
stream: updatedOptions.stream,
|
||||
provider: selectedProvider,
|
||||
enableTools: updatedOptions.enableTools
|
||||
})}`);
|
||||
|
||||
return { options: updatedOptions };
|
||||
} catch (error) {
|
||||
log.error(`Error determining default model: ${error}`);
|
||||
throw new Error(`Failed to determine AI model configuration: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add provider metadata to the options based on model name
|
||||
*/
|
||||
private addProviderMetadata(options: ChatCompletionOptions, provider: ServiceProviders, modelName: string): void {
|
||||
// Check if we already have providerMetadata
|
||||
if (options.providerMetadata) {
|
||||
// If providerMetadata exists but not modelId, add the model name
|
||||
if (!options.providerMetadata.modelId && modelName) {
|
||||
options.providerMetadata.modelId = modelName;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the explicitly provided provider - no automatic fallbacks
|
||||
let selectedProvider = provider;
|
||||
|
||||
// Set the provider metadata in the options
|
||||
if (selectedProvider) {
|
||||
// Ensure the provider is one of the valid types
|
||||
const validProvider = selectedProvider as 'openai' | 'anthropic' | 'ollama' | 'local';
|
||||
|
||||
options.providerMetadata = {
|
||||
provider: validProvider,
|
||||
modelId: modelName
|
||||
};
|
||||
|
||||
// For backward compatibility, ensure model name is set without prefix
|
||||
if (options.model && options.model.includes(':')) {
|
||||
const parsed = parseModelIdentifier(options.model);
|
||||
options.model = modelName || parsed.modelId;
|
||||
}
|
||||
|
||||
log.info(`Set provider metadata: provider=${selectedProvider}, model=${modelName}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get estimated context window for Ollama models
|
||||
*/
|
||||
private getOllamaContextWindow(model: string): number {
|
||||
// Try to find exact matches in MODEL_CAPABILITIES
|
||||
if (model in MODEL_CAPABILITIES) {
|
||||
return MODEL_CAPABILITIES[model as keyof typeof MODEL_CAPABILITIES].contextWindowTokens;
|
||||
}
|
||||
|
||||
// Estimate based on model family
|
||||
if (model.includes('llama3')) {
|
||||
return MODEL_CAPABILITIES['gpt-4'].contextWindowTokens;
|
||||
} else if (model.includes('llama2')) {
|
||||
return MODEL_CAPABILITIES['default'].contextWindowTokens;
|
||||
} else if (model.includes('mistral') || model.includes('mixtral')) {
|
||||
return MODEL_CAPABILITIES['gpt-4'].contextWindowTokens;
|
||||
} else if (model.includes('gemma')) {
|
||||
return MODEL_CAPABILITIES['gpt-4'].contextWindowTokens;
|
||||
} else {
|
||||
return MODEL_CAPABILITIES['default'].contextWindowTokens;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import type { ResponseProcessingInput } from '../interfaces.js';
|
||||
import type { ChatResponse } from '../../ai_interface.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
/**
|
||||
* Pipeline stage for processing LLM responses
|
||||
*/
|
||||
export class ResponseProcessingStage extends BasePipelineStage<ResponseProcessingInput, { text: string }> {
|
||||
constructor() {
|
||||
super('ResponseProcessing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the LLM response
|
||||
*/
|
||||
protected async process(input: ResponseProcessingInput): Promise<{ text: string }> {
|
||||
const { response, options } = input;
|
||||
log.info(`Processing LLM response from model: ${response.model}`);
|
||||
|
||||
// Perform any necessary post-processing on the response text
|
||||
let text = response.text;
|
||||
|
||||
// For Markdown formatting, ensure code blocks are properly formatted
|
||||
if (options?.showThinking && text.includes('thinking:')) {
|
||||
// Extract and format thinking section
|
||||
const thinkingMatch = text.match(/thinking:(.*?)(?=answer:|$)/s);
|
||||
if (thinkingMatch) {
|
||||
const thinking = thinkingMatch[1].trim();
|
||||
text = text.replace(/thinking:.*?(?=answer:|$)/s, `**Thinking:** \n\n\`\`\`\n${thinking}\n\`\`\`\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up response text
|
||||
text = text.replace(/^\s*assistant:\s*/i, ''); // Remove leading "Assistant:" if present
|
||||
|
||||
// Log tokens if available for monitoring
|
||||
if (response.usage) {
|
||||
log.info(`Token usage - prompt: ${response.usage.promptTokens}, completion: ${response.usage.completionTokens}, total: ${response.usage.totalTokens}`);
|
||||
}
|
||||
|
||||
return { text };
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import type { SemanticContextExtractionInput } from '../interfaces.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
/**
|
||||
* Pipeline stage for extracting semantic context from notes
|
||||
* Since vector search has been removed, this now returns empty context
|
||||
* and relies on other context extraction methods
|
||||
*/
|
||||
export class SemanticContextExtractionStage extends BasePipelineStage<SemanticContextExtractionInput, { context: string }> {
|
||||
constructor() {
|
||||
super('SemanticContextExtraction');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract semantic context based on a query
|
||||
* Returns empty context since vector search has been removed
|
||||
*/
|
||||
protected async process(input: SemanticContextExtractionInput): Promise<{ context: string }> {
|
||||
const { noteId, query } = input;
|
||||
log.info(`Semantic context extraction disabled - vector search has been removed. Using tool-based context instead for note ${noteId}`);
|
||||
|
||||
// Return empty context since we no longer use vector search
|
||||
// The LLM will rely on tool calls for context gathering
|
||||
return { context: "" };
|
||||
}
|
||||
}
|
||||
@ -1,771 +0,0 @@
|
||||
import type { ChatResponse, Message } from '../../ai_interface.js';
|
||||
import log from '../../../log.js';
|
||||
import type { StreamCallback, ToolExecutionInput } from '../interfaces.js';
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import toolRegistry from '../../tools/tool_registry.js';
|
||||
import chatStorageService from '../../chat_storage_service.js';
|
||||
import aiServiceManager from '../../ai_service_manager.js';
|
||||
|
||||
// Type definitions for tools and validation results
|
||||
interface ToolInterface {
|
||||
execute: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ToolValidationResult {
|
||||
toolCall: {
|
||||
id?: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string | Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
valid: boolean;
|
||||
tool: ToolInterface | null;
|
||||
error: string | null;
|
||||
guidance?: string; // Guidance to help the LLM select better tools/parameters
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline stage for handling LLM tool calling
|
||||
* This stage is responsible for:
|
||||
* 1. Detecting tool calls in LLM responses
|
||||
* 2. Executing the appropriate tools
|
||||
* 3. Adding tool results back to the conversation
|
||||
* 4. Determining if we need to make another call to the LLM
|
||||
*/
|
||||
export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> {
|
||||
constructor() {
|
||||
super('ToolCalling');
|
||||
// Vector search tool has been removed - no preloading needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the LLM response and execute any tool calls
|
||||
*/
|
||||
protected async process(input: ToolExecutionInput): Promise<{ response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> {
|
||||
const { response, messages } = input;
|
||||
const streamCallback = input.streamCallback as StreamCallback;
|
||||
|
||||
log.info(`========== TOOL CALLING STAGE ENTRY ==========`);
|
||||
log.info(`Response provider: ${response.provider}, model: ${response.model || 'unknown'}`);
|
||||
|
||||
log.info(`LLM requested ${response.tool_calls?.length || 0} tool calls from provider: ${response.provider}`);
|
||||
|
||||
// Check if the response has tool calls
|
||||
if (!response.tool_calls || response.tool_calls.length === 0) {
|
||||
// No tool calls, return original response and messages
|
||||
log.info(`No tool calls detected in response from provider: ${response.provider}`);
|
||||
log.info(`===== EXITING TOOL CALLING STAGE: No tool_calls =====`);
|
||||
return { response, needsFollowUp: false, messages };
|
||||
}
|
||||
|
||||
// Log response details for debugging
|
||||
if (response.text) {
|
||||
log.info(`Response text: "${response.text.substring(0, 200)}${response.text.length > 200 ? '...' : ''}"`);
|
||||
}
|
||||
|
||||
// Check if the registry has any tools
|
||||
const registryTools = toolRegistry.getAllTools();
|
||||
|
||||
// Convert ToolHandler[] to ToolInterface[] with proper type safety
|
||||
const availableTools: ToolInterface[] = registryTools.map(tool => {
|
||||
// Create a proper ToolInterface from the ToolHandler
|
||||
const toolInterface: ToolInterface = {
|
||||
// Pass through the execute method
|
||||
execute: (args: Record<string, unknown>) => tool.execute(args),
|
||||
// Include other properties from the tool definition
|
||||
...tool.definition
|
||||
};
|
||||
return toolInterface;
|
||||
});
|
||||
log.info(`Available tools in registry: ${availableTools.length}`);
|
||||
|
||||
// Log available tools for debugging
|
||||
if (availableTools.length > 0) {
|
||||
const availableToolNames = availableTools.map(t => {
|
||||
// Safely access the name property using type narrowing
|
||||
if (t && typeof t === 'object' && 'definition' in t &&
|
||||
t.definition && typeof t.definition === 'object' &&
|
||||
'function' in t.definition && t.definition.function &&
|
||||
typeof t.definition.function === 'object' &&
|
||||
'name' in t.definition.function &&
|
||||
typeof t.definition.function.name === 'string') {
|
||||
return t.definition.function.name;
|
||||
}
|
||||
return 'unknown';
|
||||
}).join(', ');
|
||||
log.info(`Available tools: ${availableToolNames}`);
|
||||
}
|
||||
|
||||
if (availableTools.length === 0) {
|
||||
log.error(`No tools available in registry, cannot execute tool calls`);
|
||||
// Try to initialize tools as a recovery step
|
||||
try {
|
||||
log.info('Attempting to initialize tools as recovery step');
|
||||
// Tools are already initialized in the AIServiceManager constructor
|
||||
// No need to initialize them again
|
||||
const toolCount = toolRegistry.getAllTools().length;
|
||||
log.info(`After recovery initialization: ${toolCount} tools available`);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Failed to initialize tools in recovery step: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a copy of messages to add the assistant message with tool calls
|
||||
const updatedMessages = [...messages];
|
||||
|
||||
// Add the assistant message with the tool calls
|
||||
updatedMessages.push({
|
||||
role: 'assistant',
|
||||
content: response.text || "",
|
||||
tool_calls: response.tool_calls
|
||||
});
|
||||
|
||||
// Execute each tool call and add results to messages
|
||||
log.info(`========== STARTING TOOL EXECUTION ==========`);
|
||||
log.info(`Executing ${response.tool_calls?.length || 0} tool calls in parallel`);
|
||||
|
||||
const executionStartTime = Date.now();
|
||||
|
||||
// First validate all tools before execution
|
||||
log.info(`Validating ${response.tool_calls?.length || 0} tools before execution`);
|
||||
const validationResults: ToolValidationResult[] = await Promise.all((response.tool_calls || []).map(async (toolCall) => {
|
||||
try {
|
||||
// Get the tool from registry
|
||||
const tool = toolRegistry.getTool(toolCall.function.name);
|
||||
|
||||
if (!tool) {
|
||||
log.error(`Tool not found in registry: ${toolCall.function.name}`);
|
||||
// Generate guidance for the LLM when a tool is not found
|
||||
const guidance = this.generateToolGuidance(toolCall.function.name, `Tool not found: ${toolCall.function.name}`);
|
||||
return {
|
||||
toolCall,
|
||||
valid: false,
|
||||
tool: null,
|
||||
error: `Tool not found: ${toolCall.function.name}`,
|
||||
guidance // Add guidance for the LLM
|
||||
};
|
||||
}
|
||||
|
||||
// Validate the tool before execution
|
||||
// Use unknown as an intermediate step for type conversion
|
||||
const isToolValid = await this.validateToolBeforeExecution(tool as unknown as ToolInterface, toolCall.function.name);
|
||||
if (!isToolValid) {
|
||||
throw new Error(`Tool '${toolCall.function.name}' failed validation before execution`);
|
||||
}
|
||||
|
||||
return {
|
||||
toolCall,
|
||||
valid: true,
|
||||
tool: tool as unknown as ToolInterface,
|
||||
error: null
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
toolCall,
|
||||
valid: false,
|
||||
tool: null,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
// Execute the validated tools
|
||||
const toolResults = await Promise.all(validationResults.map(async (validation, index) => {
|
||||
const { toolCall, valid, tool, error } = validation;
|
||||
|
||||
try {
|
||||
log.info(`========== TOOL CALL ${index + 1} OF ${response.tool_calls?.length || 0} ==========`);
|
||||
log.info(`Tool call ${index + 1} received - Name: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
|
||||
|
||||
// Log parameters
|
||||
const argsStr = typeof toolCall.function.arguments === 'string'
|
||||
? toolCall.function.arguments
|
||||
: JSON.stringify(toolCall.function.arguments);
|
||||
log.info(`Tool parameters: ${argsStr}`);
|
||||
|
||||
// If validation failed, generate guidance and throw the error
|
||||
if (!valid || !tool) {
|
||||
// If we already have guidance from validation, use it, otherwise generate it
|
||||
const toolGuidance = validation.guidance ||
|
||||
this.generateToolGuidance(toolCall.function.name,
|
||||
error || `Unknown validation error for tool '${toolCall.function.name}'`);
|
||||
|
||||
// Include the guidance in the error message
|
||||
throw new Error(`${error || `Unknown validation error for tool '${toolCall.function.name}'`}\n${toolGuidance}`);
|
||||
}
|
||||
|
||||
log.info(`Tool validated successfully: ${toolCall.function.name}`);
|
||||
|
||||
// Parse arguments (handle both string and object formats)
|
||||
let args: Record<string, unknown>;
|
||||
// At this stage, arguments should already be processed by the provider-specific service
|
||||
// But we still need to handle different formats just in case
|
||||
if (typeof toolCall.function.arguments === 'string') {
|
||||
log.info(`Received string arguments in tool calling stage: ${toolCall.function.arguments.substring(0, 50)}...`);
|
||||
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
|
||||
log.info(`Parsed JSON arguments: ${Object.keys(args).join(', ')}`);
|
||||
} catch (e: unknown) {
|
||||
// If it's not valid JSON, try to check if it's a stringified object with quotes
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
log.info(`Failed to parse arguments as JSON, trying alternative parsing: ${errorMessage}`);
|
||||
|
||||
// Sometimes LLMs return stringified JSON with escaped quotes or incorrect quotes
|
||||
// Try to clean it up
|
||||
try {
|
||||
const cleaned = toolCall.function.arguments
|
||||
.replace(/^['"]/g, '') // Remove surrounding quotes
|
||||
.replace(/['"]$/g, '') // Remove surrounding quotes
|
||||
.replace(/\\"/g, '"') // Replace escaped quotes
|
||||
.replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names
|
||||
.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); // Add quotes around unquoted property names
|
||||
|
||||
log.info(`Cleaned argument string: ${cleaned}`);
|
||||
args = JSON.parse(cleaned) as Record<string, unknown>;
|
||||
log.info(`Successfully parsed cleaned arguments: ${Object.keys(args).join(', ')}`);
|
||||
} catch (cleanError: unknown) {
|
||||
// If all parsing fails, treat it as a text argument
|
||||
const cleanErrorMessage = cleanError instanceof Error ? cleanError.message : String(cleanError);
|
||||
log.info(`Failed to parse cleaned arguments: ${cleanErrorMessage}`);
|
||||
args = { text: toolCall.function.arguments };
|
||||
log.info(`Using text argument: ${(args.text as string).substring(0, 50)}...`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Arguments are already an object
|
||||
args = toolCall.function.arguments as Record<string, unknown>;
|
||||
log.info(`Using object arguments with keys: ${Object.keys(args).join(', ')}`);
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
log.info(`================ EXECUTING TOOL: ${toolCall.function.name} ================`);
|
||||
log.info(`Tool parameters: ${Object.keys(args).join(', ')}`);
|
||||
log.info(`Parameters values: ${Object.entries(args).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ')}`);
|
||||
|
||||
// Emit tool start event if streaming is enabled
|
||||
if (streamCallback) {
|
||||
const toolExecutionData = {
|
||||
action: 'start',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: args
|
||||
},
|
||||
type: 'start' as const,
|
||||
progress: {
|
||||
current: index + 1,
|
||||
total: response.tool_calls?.length || 1,
|
||||
status: 'initializing',
|
||||
message: `Starting ${toolCall.function.name} execution...`,
|
||||
estimatedDuration: this.getEstimatedDuration(toolCall.function.name)
|
||||
}
|
||||
};
|
||||
|
||||
// Don't wait for this to complete, but log any errors
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution start event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
const executionStart = Date.now();
|
||||
let result;
|
||||
try {
|
||||
log.info(`Starting tool execution for ${toolCall.function.name}...`);
|
||||
|
||||
// Send progress update during execution
|
||||
if (streamCallback) {
|
||||
const progressData = {
|
||||
action: 'progress',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: args
|
||||
},
|
||||
type: 'progress' as const,
|
||||
progress: {
|
||||
current: index + 1,
|
||||
total: response.tool_calls?.length || 1,
|
||||
status: 'executing',
|
||||
message: `Executing ${toolCall.function.name}...`,
|
||||
startTime: executionStart
|
||||
}
|
||||
};
|
||||
|
||||
const progressResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: progressData
|
||||
});
|
||||
if (progressResult instanceof Promise) {
|
||||
progressResult.catch((e: Error) => log.error(`Error sending tool execution progress event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
result = await tool.execute(args);
|
||||
const executionTime = Date.now() - executionStart;
|
||||
log.info(`================ TOOL EXECUTION COMPLETED in ${executionTime}ms ================`);
|
||||
|
||||
// Record this successful tool execution if there's a sessionId available
|
||||
if (input.options?.sessionId) {
|
||||
try {
|
||||
await chatStorageService.recordToolExecution(
|
||||
input.options.sessionId,
|
||||
toolCall.function.name,
|
||||
toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
args,
|
||||
result,
|
||||
undefined // No error for successful execution
|
||||
);
|
||||
} catch (storageError) {
|
||||
log.error(`Failed to record tool execution in chat storage: ${storageError}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit tool completion event if streaming is enabled
|
||||
if (streamCallback) {
|
||||
const resultSummary = typeof result === 'string'
|
||||
? result.substring(0, 200) + (result.length > 200 ? '...' : '')
|
||||
: `Object with ${Object.keys(result).length} properties`;
|
||||
|
||||
const toolExecutionData = {
|
||||
action: 'complete',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: {} as Record<string, unknown>
|
||||
},
|
||||
result: typeof result === 'string' ? result : result as Record<string, unknown>,
|
||||
type: 'complete' as const,
|
||||
progress: {
|
||||
current: index + 1,
|
||||
total: response.tool_calls?.length || 1,
|
||||
status: 'completed',
|
||||
message: `${toolCall.function.name} completed successfully`,
|
||||
executionTime: executionTime,
|
||||
resultSummary: resultSummary
|
||||
}
|
||||
};
|
||||
|
||||
// Don't wait for this to complete, but log any errors
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution complete event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
} catch (execError: unknown) {
|
||||
const executionTime = Date.now() - executionStart;
|
||||
const errorMessage = execError instanceof Error ? execError.message : String(execError);
|
||||
log.error(`================ TOOL EXECUTION FAILED in ${executionTime}ms: ${errorMessage} ================`);
|
||||
|
||||
// Generate guidance for the failed tool execution
|
||||
const toolGuidance = this.generateToolGuidance(toolCall.function.name, errorMessage);
|
||||
|
||||
// Add the guidance to the error message for the LLM
|
||||
const enhancedErrorMessage = `${errorMessage}\n${toolGuidance}`;
|
||||
|
||||
// Record this failed tool execution if there's a sessionId available
|
||||
if (input.options?.sessionId) {
|
||||
try {
|
||||
await chatStorageService.recordToolExecution(
|
||||
input.options.sessionId,
|
||||
toolCall.function.name,
|
||||
toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
args,
|
||||
"", // No result for failed execution
|
||||
enhancedErrorMessage // Use enhanced error message with guidance
|
||||
);
|
||||
} catch (storageError) {
|
||||
log.error(`Failed to record tool execution error in chat storage: ${storageError}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit tool error event if streaming is enabled
|
||||
if (streamCallback) {
|
||||
const toolExecutionData = {
|
||||
action: 'error',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: {} as Record<string, unknown>
|
||||
},
|
||||
error: enhancedErrorMessage, // Include guidance in the error message
|
||||
type: 'error' as const,
|
||||
progress: {
|
||||
current: index + 1,
|
||||
total: response.tool_calls?.length || 1,
|
||||
status: 'failed',
|
||||
message: `${toolCall.function.name} failed: ${errorMessage.substring(0, 100)}...`,
|
||||
executionTime: executionTime,
|
||||
errorType: execError instanceof Error ? execError.constructor.name : 'UnknownError'
|
||||
}
|
||||
};
|
||||
|
||||
// Don't wait for this to complete, but log any errors
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Modify the error to include our guidance
|
||||
if (execError instanceof Error) {
|
||||
execError.message = enhancedErrorMessage;
|
||||
}
|
||||
throw execError;
|
||||
}
|
||||
|
||||
// Log execution result
|
||||
const resultSummary = typeof result === 'string'
|
||||
? `${result.substring(0, 100)}...`
|
||||
: `Object with keys: ${Object.keys(result).join(', ')}`;
|
||||
const executionTime = Date.now() - executionStart;
|
||||
log.info(`Tool execution completed in ${executionTime}ms - Result: ${resultSummary}`);
|
||||
|
||||
// Return result with tool call ID
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
result
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error executing tool ${toolCall.function.name}: ${errorMessage}`);
|
||||
|
||||
// Emit tool error event if not already handled in the try/catch above
|
||||
// and if streaming is enabled
|
||||
// Need to check if error is an object with a name property of type string
|
||||
const isExecutionError = typeof error === 'object' && error !== null &&
|
||||
'name' in error && (error as { name: unknown }).name === "ExecutionError";
|
||||
|
||||
if (streamCallback && !isExecutionError) {
|
||||
const toolExecutionData = {
|
||||
action: 'error',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: {} as Record<string, unknown>
|
||||
},
|
||||
error: errorMessage,
|
||||
type: 'error' as const
|
||||
};
|
||||
|
||||
// Don't wait for this to complete, but log any errors
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Return error message as result
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
result: `Error: ${errorMessage}`
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
const totalExecutionTime = Date.now() - executionStartTime;
|
||||
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
|
||||
log.info(`Completed execution of ${toolResults.length} tools in ${totalExecutionTime}ms`);
|
||||
|
||||
// Add each tool result to the messages array
|
||||
const toolResultMessages: Message[] = [];
|
||||
let hasEmptyResults = false;
|
||||
|
||||
for (const result of toolResults) {
|
||||
const { toolCallId, name, result: toolResult } = result;
|
||||
|
||||
// Format result for message
|
||||
const resultContent = typeof toolResult === 'string'
|
||||
? toolResult
|
||||
: JSON.stringify(toolResult, null, 2);
|
||||
|
||||
// Check if result is empty or unhelpful
|
||||
const isEmptyResult = this.isEmptyToolResult(toolResult, name);
|
||||
if (isEmptyResult && !resultContent.startsWith('Error:')) {
|
||||
hasEmptyResults = true;
|
||||
log.info(`Empty result detected for tool ${name}. Will add suggestion to try different parameters.`);
|
||||
}
|
||||
|
||||
// Add enhancement for empty results and continuation signals
|
||||
let enhancedContent = resultContent;
|
||||
if (isEmptyResult && !resultContent.startsWith('Error:')) {
|
||||
enhancedContent = `${resultContent}\n\nCONTINUATION REQUIRED: This tool returned no useful results with the provided parameters. You MUST immediately try additional searches with broader terms, alternative keywords, or different tools. Do not stop here - continue your investigation.`;
|
||||
} else if (!resultContent.startsWith('Error:')) {
|
||||
// Add continuation signal for successful results too
|
||||
enhancedContent = `${resultContent}\n\nCONTINUATION: Analyze these results and determine if additional tools are needed to provide a comprehensive response. Consider follow-up searches, reading specific notes, or cross-referencing with other tools.`;
|
||||
}
|
||||
|
||||
// Add a new message for the tool result
|
||||
const toolMessage: Message = {
|
||||
role: 'tool',
|
||||
content: enhancedContent,
|
||||
name: name,
|
||||
tool_call_id: toolCallId
|
||||
};
|
||||
|
||||
// Log detailed info about each tool result
|
||||
log.info(`-------- Tool Result for ${name} (ID: ${toolCallId}) --------`);
|
||||
log.info(`Result type: ${typeof toolResult}`);
|
||||
log.info(`Result preview: ${resultContent.substring(0, 150)}${resultContent.length > 150 ? '...' : ''}`);
|
||||
log.info(`Tool result status: ${resultContent.startsWith('Error:') ? 'ERROR' : isEmptyResult ? 'EMPTY' : 'SUCCESS'}`);
|
||||
|
||||
updatedMessages.push(toolMessage);
|
||||
toolResultMessages.push(toolMessage);
|
||||
}
|
||||
|
||||
// Log the decision about follow-up
|
||||
log.info(`========== FOLLOW-UP DECISION ==========`);
|
||||
const hasToolResults = toolResultMessages.length > 0;
|
||||
const hasErrors = toolResultMessages.some(msg => msg.content.startsWith('Error:'));
|
||||
const needsFollowUp = hasToolResults;
|
||||
|
||||
log.info(`Follow-up needed: ${needsFollowUp}`);
|
||||
log.info(`Reasoning: ${hasToolResults ? 'Has tool results to process' : 'No tool results'} ${hasErrors ? ', contains errors' : ''} ${hasEmptyResults ? ', contains empty results' : ''}`);
|
||||
|
||||
// Add system message to ensure continuation - always add this for any tool results
|
||||
if (needsFollowUp) {
|
||||
log.info('Adding system message to ensure LLM continues with additional tools');
|
||||
|
||||
let directiveMessage = '';
|
||||
|
||||
// Handle empty results with aggressive re-search instructions
|
||||
if (hasEmptyResults) {
|
||||
directiveMessage = `CRITICAL: Empty results detected. YOU MUST NOT GIVE UP. IMMEDIATELY try alternative approaches:
|
||||
- Use broader search terms (remove specific qualifiers)
|
||||
- Try synonyms and related concepts
|
||||
- Use different search tools (if keyword_search failed, try search_notes)
|
||||
- Remove date/time constraints and try general terms
|
||||
DO NOT respond to the user until you've tried at least 2-3 additional search variations.`;
|
||||
} else {
|
||||
// For successful results, provide comprehensive follow-up instructions
|
||||
directiveMessage = `TOOL ANALYSIS CONTINUATION: You have received tool results above. This is NOT the end of your investigation. You must:
|
||||
|
||||
1. Analyze the results thoroughly
|
||||
2. Determine if additional information is needed
|
||||
3. Use follow-up tools to gather comprehensive information
|
||||
4. If search results included note IDs, use read_note to get full content
|
||||
5. Cross-reference findings with alternative search approaches
|
||||
6. Continue investigation until you can provide a complete, well-researched response
|
||||
|
||||
REMEMBER: Execute multiple tools in sequence to build comprehensive answers. The user expects thorough investigation with systematic tool usage.
|
||||
|
||||
Continue your systematic investigation now.`;
|
||||
}
|
||||
|
||||
updatedMessages.push({
|
||||
role: 'system',
|
||||
content: directiveMessage
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`Total messages to return to pipeline: ${updatedMessages.length}`);
|
||||
log.info(`Last 3 messages in conversation:`);
|
||||
const lastMessages = updatedMessages.slice(-3);
|
||||
lastMessages.forEach((msg, idx) => {
|
||||
const position = updatedMessages.length - lastMessages.length + idx;
|
||||
log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`);
|
||||
});
|
||||
|
||||
return {
|
||||
response,
|
||||
messages: updatedMessages,
|
||||
needsFollowUp
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate a tool before execution
|
||||
* @param tool The tool to validate
|
||||
* @param toolName The name of the tool
|
||||
*/
|
||||
private async validateToolBeforeExecution(tool: ToolInterface, toolName: string): Promise<boolean> {
|
||||
try {
|
||||
if (!tool) {
|
||||
log.error(`Tool '${toolName}' not found or failed validation`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate execute method
|
||||
if (!tool.execute || typeof tool.execute !== 'function') {
|
||||
log.error(`Tool '${toolName}' is missing execute method`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// search_notes tool now uses context handler instead of vector search
|
||||
if (toolName === 'search_notes') {
|
||||
log.info(`Tool '${toolName}' validated - uses context handler instead of vector search`);
|
||||
}
|
||||
|
||||
// Add additional tool-specific validations here
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error validating tool before execution: ${errorMessage}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate guidance for the LLM when a tool fails or is not found
|
||||
* @param toolName The name of the tool that failed
|
||||
* @param errorMessage The error message from the failed tool
|
||||
* @returns A guidance message for the LLM with suggestions of what to try next
|
||||
*/
|
||||
private generateToolGuidance(toolName: string, errorMessage: string): string {
|
||||
// Get all available tool names for recommendations
|
||||
const availableTools = toolRegistry.getAllTools();
|
||||
const availableToolNames = availableTools
|
||||
.map(t => {
|
||||
if (t && typeof t === 'object' && 'definition' in t &&
|
||||
t.definition && typeof t.definition === 'object' &&
|
||||
'function' in t.definition && t.definition.function &&
|
||||
typeof t.definition.function === 'object' &&
|
||||
'name' in t.definition.function &&
|
||||
typeof t.definition.function.name === 'string') {
|
||||
return t.definition.function.name;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(name => name !== '');
|
||||
|
||||
// Create specific guidance based on the error and tool
|
||||
let guidance = `TOOL GUIDANCE: The tool '${toolName}' failed with error: ${errorMessage}.\n`;
|
||||
|
||||
// Add suggestions based on the specific tool and error
|
||||
if (toolName === 'attribute_search' && errorMessage.includes('Invalid attribute type')) {
|
||||
guidance += "CRITICAL REQUIREMENT: The 'attribute_search' tool requires 'attributeType' parameter that must be EXACTLY 'label' or 'relation' (lowercase, no other values).\n";
|
||||
guidance += "CORRECT EXAMPLE: { \"attributeType\": \"label\", \"attributeName\": \"important\", \"attributeValue\": \"yes\" }\n";
|
||||
guidance += "INCORRECT EXAMPLE: { \"attributeType\": \"Label\", ... } - Case matters! Must be lowercase.\n";
|
||||
}
|
||||
else if (errorMessage.includes('Tool not found')) {
|
||||
// Provide guidance on available search tools if a tool wasn't found
|
||||
const searchTools = availableToolNames.filter(name => name.includes('search'));
|
||||
guidance += `AVAILABLE SEARCH TOOLS: ${searchTools.join(', ')}\n`;
|
||||
guidance += "TRY SEARCH NOTES: For semantic matches, use 'search_notes' with a query parameter.\n";
|
||||
guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n";
|
||||
}
|
||||
else if (errorMessage.includes('missing required parameter')) {
|
||||
// Provide parameter guidance based on the tool name
|
||||
if (toolName === 'search_notes') {
|
||||
guidance += "REQUIRED PARAMETERS: The 'search_notes' tool requires a 'query' parameter.\n";
|
||||
guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n";
|
||||
} else if (toolName === 'keyword_search') {
|
||||
guidance += "REQUIRED PARAMETERS: The 'keyword_search' tool requires a 'query' parameter.\n";
|
||||
guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Add general recommendations including new helper tools
|
||||
if (!toolName.includes('search_notes')) {
|
||||
guidance += "RECOMMENDATION: If specific searches fail, try the 'search_notes' tool which performs semantic searches.\n";
|
||||
}
|
||||
|
||||
// Encourage continued tool usage
|
||||
guidance += "\nTry alternative tools immediately. Use discover_tools if unsure which tool to use next.";
|
||||
|
||||
return guidance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get estimated duration for a tool execution (in milliseconds)
|
||||
* @param toolName The name of the tool
|
||||
* @returns Estimated duration in milliseconds
|
||||
*/
|
||||
private getEstimatedDuration(toolName: string): number {
|
||||
// Tool-specific duration estimates based on typical execution times
|
||||
const estimations = {
|
||||
'search_notes': 2000,
|
||||
'read_note': 1000,
|
||||
'keyword_search': 1500,
|
||||
'attribute_search': 1200,
|
||||
'discover_tools': 500,
|
||||
'note_by_path': 800,
|
||||
'template_search': 1000
|
||||
};
|
||||
|
||||
return estimations[toolName as keyof typeof estimations] || 1500; // Default 1.5 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a tool result is effectively empty or unhelpful
|
||||
* @param result The result from the tool execution
|
||||
* @param toolName The name of the tool that was executed
|
||||
* @returns true if the result is considered empty or unhelpful
|
||||
*/
|
||||
private isEmptyToolResult(result: unknown, toolName: string): boolean {
|
||||
// Handle string results
|
||||
if (typeof result === 'string') {
|
||||
const trimmed = result.trim();
|
||||
if (trimmed === '' || trimmed === '[]' || trimmed === '{}') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tool-specific empty results (for string responses)
|
||||
if (toolName === 'search_notes' &&
|
||||
(trimmed === 'No matching notes found.' ||
|
||||
trimmed.includes('No results found') ||
|
||||
trimmed.includes('No matches found') ||
|
||||
trimmed.includes('No notes found'))) {
|
||||
// This is a valid result (empty, but valid), don't mark as empty so LLM can see feedback
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (toolName === 'keyword_search' &&
|
||||
(trimmed.includes('No matches found') ||
|
||||
trimmed.includes('No results for'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Handle object/array results
|
||||
else if (result !== null && typeof result === 'object') {
|
||||
// Check if it's an empty array
|
||||
if (Array.isArray(result) && result.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's an object with no meaningful properties
|
||||
// or with properties indicating empty results
|
||||
if (!Array.isArray(result)) {
|
||||
if (Object.keys(result).length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tool-specific object empty checks
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
|
||||
if (toolName === 'search_notes' &&
|
||||
'results' in resultObj &&
|
||||
Array.isArray(resultObj.results) &&
|
||||
resultObj.results.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,486 +0,0 @@
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import type { ToolExecutionInput, StreamCallback } from '../interfaces.js';
|
||||
import type { ChatResponse, Message } from '../../ai_interface.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
interface UserInteractionConfig {
|
||||
enableConfirmation: boolean;
|
||||
enableCancellation: boolean;
|
||||
confirmationTimeout: number; // milliseconds
|
||||
autoConfirmLowRisk: boolean;
|
||||
requiredConfirmationTools: string[];
|
||||
}
|
||||
|
||||
interface PendingInteraction {
|
||||
id: string;
|
||||
toolCall: any;
|
||||
timestamp: number;
|
||||
timeoutHandle?: NodeJS.Timeout;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
type InteractionResponse = 'confirm' | 'cancel' | 'timeout';
|
||||
|
||||
/**
|
||||
* Enhanced User Interaction Pipeline Stage
|
||||
* Provides real-time confirmation/cancellation capabilities for tool execution
|
||||
*/
|
||||
export class UserInteractionStage extends BasePipelineStage<ToolExecutionInput, { response: ChatResponse, needsFollowUp: boolean, messages: Message[], userInteractions?: any[] }> {
|
||||
|
||||
private config: UserInteractionConfig;
|
||||
private pendingInteractions: Map<string, PendingInteraction> = new Map();
|
||||
private interactionCallbacks: Map<string, (response: InteractionResponse) => void> = new Map();
|
||||
|
||||
constructor(config?: Partial<UserInteractionConfig>) {
|
||||
super('UserInteraction');
|
||||
|
||||
this.config = {
|
||||
enableConfirmation: true,
|
||||
enableCancellation: true,
|
||||
confirmationTimeout: 15000, // 15 seconds
|
||||
autoConfirmLowRisk: true,
|
||||
requiredConfirmationTools: ['attribute_search', 'read_note'],
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tool execution with user interaction capabilities
|
||||
*/
|
||||
protected async process(input: ToolExecutionInput): Promise<{ response: ChatResponse, needsFollowUp: boolean, messages: Message[], userInteractions?: any[] }> {
|
||||
const { response } = input;
|
||||
|
||||
// If no tool calls or interactions disabled, pass through
|
||||
if (!response.tool_calls || response.tool_calls.length === 0 || !this.config.enableConfirmation) {
|
||||
return {
|
||||
response,
|
||||
needsFollowUp: false,
|
||||
messages: input.messages,
|
||||
userInteractions: []
|
||||
};
|
||||
}
|
||||
|
||||
log.info(`========== USER INTERACTION STAGE PROCESSING ==========`);
|
||||
log.info(`Processing ${response.tool_calls.length} tool calls with user interaction controls`);
|
||||
|
||||
const processedToolCalls: any[] = [];
|
||||
const userInteractions: any[] = [];
|
||||
const updatedMessages = [...input.messages];
|
||||
|
||||
// Process each tool call with interaction controls
|
||||
for (let i = 0; i < response.tool_calls.length; i++) {
|
||||
const toolCall = response.tool_calls[i];
|
||||
|
||||
const interactionResult = await this.processToolCallWithInteraction(toolCall, input, i);
|
||||
|
||||
if (interactionResult) {
|
||||
processedToolCalls.push(interactionResult.toolCall);
|
||||
updatedMessages.push(interactionResult.message);
|
||||
|
||||
if (interactionResult.interaction) {
|
||||
userInteractions.push(interactionResult.interaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create enhanced response with interaction metadata
|
||||
const enhancedResponse: ChatResponse = {
|
||||
...response,
|
||||
tool_calls: processedToolCalls,
|
||||
interaction_metadata: {
|
||||
total_interactions: userInteractions.length,
|
||||
confirmed: userInteractions.filter((i: any) => i.response === 'confirm').length,
|
||||
cancelled: userInteractions.filter((i: any) => i.response === 'cancel').length,
|
||||
timedout: userInteractions.filter((i: any) => i.response === 'timeout').length
|
||||
}
|
||||
};
|
||||
|
||||
const needsFollowUp = processedToolCalls.length > 0;
|
||||
|
||||
log.info(`User interaction complete: ${userInteractions.length} interactions processed`);
|
||||
|
||||
return {
|
||||
response: enhancedResponse,
|
||||
needsFollowUp,
|
||||
messages: updatedMessages,
|
||||
userInteractions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a tool call with user interaction controls
|
||||
*/
|
||||
private async processToolCallWithInteraction(
|
||||
toolCall: any,
|
||||
input: ToolExecutionInput,
|
||||
index: number
|
||||
): Promise<{ toolCall: any, message: Message, interaction?: any } | null> {
|
||||
|
||||
const toolName = toolCall.function.name;
|
||||
const riskLevel = this.assessToolRiskLevel(toolName);
|
||||
|
||||
// Determine if confirmation is required
|
||||
const requiresConfirmation = this.shouldRequireConfirmation(toolName, riskLevel);
|
||||
|
||||
if (!requiresConfirmation) {
|
||||
// Execute immediately for low-risk tools
|
||||
log.info(`Tool ${toolName} is low-risk, executing immediately`);
|
||||
return await this.executeToolDirectly(toolCall, input);
|
||||
}
|
||||
|
||||
// Request user confirmation
|
||||
log.info(`Tool ${toolName} requires user confirmation (risk level: ${riskLevel})`);
|
||||
|
||||
const interactionId = this.generateInteractionId();
|
||||
const interaction = await this.requestUserConfirmation(toolCall, interactionId, input.streamCallback);
|
||||
|
||||
if (interaction.response === 'confirm') {
|
||||
log.info(`User confirmed execution of ${toolName}`);
|
||||
const result = await this.executeToolDirectly(toolCall, input);
|
||||
return {
|
||||
...result!,
|
||||
interaction
|
||||
};
|
||||
} else if (interaction.response === 'cancel') {
|
||||
log.info(`User cancelled execution of ${toolName}`);
|
||||
return {
|
||||
toolCall,
|
||||
message: {
|
||||
role: 'tool',
|
||||
content: `USER_CANCELLED: Execution of ${toolName} was cancelled by user request.`,
|
||||
name: toolName,
|
||||
tool_call_id: toolCall.id
|
||||
},
|
||||
interaction
|
||||
};
|
||||
} else {
|
||||
// Timeout
|
||||
log.info(`User confirmation timeout for ${toolName}, executing with default action`);
|
||||
const result = await this.executeToolDirectly(toolCall, input);
|
||||
return {
|
||||
...result!,
|
||||
interaction: { ...interaction, response: 'timeout_executed' }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assess the risk level of a tool
|
||||
*/
|
||||
private assessToolRiskLevel(toolName: string): 'low' | 'medium' | 'high' {
|
||||
const riskLevels = {
|
||||
// Low risk - read-only operations
|
||||
'search_notes': 'low',
|
||||
'keyword_search': 'low',
|
||||
'discover_tools': 'low',
|
||||
'template_search': 'low',
|
||||
|
||||
// Medium risk - specific data access
|
||||
'read_note': 'medium',
|
||||
'note_by_path': 'medium',
|
||||
|
||||
// High risk - complex queries or potential data modification
|
||||
'attribute_search': 'high'
|
||||
};
|
||||
|
||||
return (riskLevels as any)[toolName] || 'medium';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a tool requires user confirmation
|
||||
*/
|
||||
private shouldRequireConfirmation(toolName: string, riskLevel: string): boolean {
|
||||
// Always require confirmation for high-risk tools
|
||||
if (riskLevel === 'high') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if tool is in the required confirmation list
|
||||
if (this.config.requiredConfirmationTools.includes(toolName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auto-confirm low-risk tools if enabled
|
||||
if (riskLevel === 'low' && this.config.autoConfirmLowRisk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default to requiring confirmation for medium-risk tools
|
||||
return riskLevel === 'medium';
|
||||
}
|
||||
|
||||
/**
|
||||
* Request user confirmation for tool execution
|
||||
*/
|
||||
private async requestUserConfirmation(
|
||||
toolCall: any,
|
||||
interactionId: string,
|
||||
streamCallback?: StreamCallback
|
||||
): Promise<any> {
|
||||
|
||||
const toolName = toolCall.function.name;
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
|
||||
// Create pending interaction
|
||||
const pendingInteraction: PendingInteraction = {
|
||||
id: interactionId,
|
||||
toolCall,
|
||||
timestamp: Date.now(),
|
||||
resolved: false
|
||||
};
|
||||
|
||||
this.pendingInteractions.set(interactionId, pendingInteraction);
|
||||
|
||||
// Send confirmation request via streaming
|
||||
if (streamCallback) {
|
||||
this.sendConfirmationRequest(streamCallback, toolCall, interactionId, args);
|
||||
}
|
||||
|
||||
// Wait for user response or timeout
|
||||
return new Promise<any>((resolve) => {
|
||||
// Set up timeout
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
if (!pendingInteraction.resolved) {
|
||||
pendingInteraction.resolved = true;
|
||||
this.pendingInteractions.delete(interactionId);
|
||||
this.interactionCallbacks.delete(interactionId);
|
||||
|
||||
resolve({
|
||||
id: interactionId,
|
||||
toolName,
|
||||
response: 'timeout',
|
||||
timestamp: Date.now(),
|
||||
duration: Date.now() - pendingInteraction.timestamp
|
||||
});
|
||||
}
|
||||
}, this.config.confirmationTimeout);
|
||||
|
||||
pendingInteraction.timeoutHandle = timeoutHandle;
|
||||
|
||||
// Set up response callback
|
||||
this.interactionCallbacks.set(interactionId, (response: InteractionResponse) => {
|
||||
if (!pendingInteraction.resolved) {
|
||||
pendingInteraction.resolved = true;
|
||||
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
this.pendingInteractions.delete(interactionId);
|
||||
this.interactionCallbacks.delete(interactionId);
|
||||
|
||||
resolve({
|
||||
id: interactionId,
|
||||
toolName,
|
||||
response,
|
||||
timestamp: Date.now(),
|
||||
duration: Date.now() - pendingInteraction.timestamp
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send confirmation request via streaming
|
||||
*/
|
||||
private sendConfirmationRequest(
|
||||
streamCallback: StreamCallback,
|
||||
toolCall: any,
|
||||
interactionId: string,
|
||||
args: Record<string, unknown>
|
||||
): void {
|
||||
|
||||
const toolName = toolCall.function.name;
|
||||
const riskLevel = this.assessToolRiskLevel(toolName);
|
||||
|
||||
// Create user-friendly description of the tool action
|
||||
const actionDescription = this.createActionDescription(toolName, args);
|
||||
|
||||
const confirmationData = {
|
||||
type: 'user_confirmation',
|
||||
action: 'request',
|
||||
interactionId,
|
||||
tool: {
|
||||
name: toolName,
|
||||
description: actionDescription,
|
||||
arguments: args,
|
||||
riskLevel
|
||||
},
|
||||
options: {
|
||||
confirm: {
|
||||
label: 'Execute',
|
||||
description: `Proceed with ${toolName}`,
|
||||
style: riskLevel === 'high' ? 'warning' : 'primary'
|
||||
},
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
description: 'Skip this tool execution',
|
||||
style: 'secondary'
|
||||
}
|
||||
},
|
||||
timeout: this.config.confirmationTimeout,
|
||||
message: `Do you want to execute ${toolName}? ${actionDescription}`
|
||||
};
|
||||
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
userInteraction: confirmationData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user-friendly action description
|
||||
*/
|
||||
private createActionDescription(toolName: string, args: Record<string, unknown>): string {
|
||||
switch (toolName) {
|
||||
case 'search_notes':
|
||||
return `Search your notes for: "${args.query || 'unknown query'}"`;
|
||||
|
||||
case 'read_note':
|
||||
return `Read note with ID: ${args.noteId || 'unknown'}`;
|
||||
|
||||
case 'keyword_search':
|
||||
return `Search for keyword: "${args.query || 'unknown query'}"`;
|
||||
|
||||
case 'attribute_search':
|
||||
return `Search for ${args.attributeType || 'attribute'}: "${args.attributeName || 'unknown'}"`;
|
||||
|
||||
case 'note_by_path':
|
||||
return `Find note at path: "${args.path || 'unknown path'}"`;
|
||||
|
||||
case 'discover_tools':
|
||||
return `Discover available tools`;
|
||||
|
||||
default:
|
||||
return `Execute ${toolName} with provided parameters`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute tool directly without confirmation
|
||||
*/
|
||||
private async executeToolDirectly(
|
||||
toolCall: any,
|
||||
input: ToolExecutionInput
|
||||
): Promise<{ toolCall: any, message: Message }> {
|
||||
|
||||
const toolName = toolCall.function.name;
|
||||
|
||||
try {
|
||||
// Import and use tool registry
|
||||
const toolRegistry = (await import('../../tools/tool_registry.js')).default;
|
||||
const tool = toolRegistry.getTool(toolName);
|
||||
|
||||
if (!tool) {
|
||||
throw new Error(`Tool not found: ${toolName}`);
|
||||
}
|
||||
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
const result = await tool.execute(args);
|
||||
|
||||
return {
|
||||
toolCall,
|
||||
message: {
|
||||
role: 'tool',
|
||||
content: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
|
||||
name: toolName,
|
||||
tool_call_id: toolCall.id
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error executing tool ${toolName}: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
toolCall,
|
||||
message: {
|
||||
role: 'tool',
|
||||
content: `Error: ${errorMessage}`,
|
||||
name: toolName,
|
||||
tool_call_id: toolCall.id
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool arguments safely
|
||||
*/
|
||||
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
return JSON.parse(args);
|
||||
} catch {
|
||||
return { query: args };
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique interaction ID
|
||||
*/
|
||||
private generateInteractionId(): string {
|
||||
return `interaction_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user response to confirmation request
|
||||
* This method would be called by the frontend/WebSocket handler
|
||||
*/
|
||||
public handleUserResponse(interactionId: string, response: 'confirm' | 'cancel'): boolean {
|
||||
const callback = this.interactionCallbacks.get(interactionId);
|
||||
|
||||
if (callback) {
|
||||
log.info(`Received user response for interaction ${interactionId}: ${response}`);
|
||||
callback(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
log.error(`No callback found for interaction ${interactionId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending interactions
|
||||
*/
|
||||
public cancelAllPendingInteractions(): void {
|
||||
log.info(`Cancelling ${this.pendingInteractions.size} pending interactions`);
|
||||
|
||||
for (const [id, interaction] of this.pendingInteractions.entries()) {
|
||||
if (interaction.timeoutHandle) {
|
||||
clearTimeout(interaction.timeoutHandle);
|
||||
}
|
||||
|
||||
const callback = this.interactionCallbacks.get(id);
|
||||
if (callback && !interaction.resolved) {
|
||||
callback('cancel');
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingInteractions.clear();
|
||||
this.interactionCallbacks.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending interactions (for status monitoring)
|
||||
*/
|
||||
public getPendingInteractions(): Array<{ id: string, toolName: string, timestamp: number }> {
|
||||
return Array.from(this.pendingInteractions.values()).map(interaction => ({
|
||||
id: interaction.id,
|
||||
toolName: interaction.toolCall.function.name,
|
||||
timestamp: interaction.timestamp
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
public updateConfig(newConfig: Partial<UserInteractionConfig>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
log.info(`User interaction configuration updated: ${JSON.stringify(newConfig)}`);
|
||||
}
|
||||
}
|
||||
@ -97,10 +97,12 @@ export class AnthropicService extends BaseAIService {
|
||||
providerOptions.betaVersion
|
||||
);
|
||||
|
||||
// Log API key format (without revealing the actual key)
|
||||
// Log API key format (without revealing the actual key) - only in debug mode
|
||||
if (process.env.LLM_DEBUG === 'true') {
|
||||
const apiKeyPrefix = providerOptions.apiKey?.substring(0, 7) || 'undefined';
|
||||
const apiKeyLength = providerOptions.apiKey?.length || 0;
|
||||
log.info(`[DEBUG] Using Anthropic API key with prefix '${apiKeyPrefix}...' and length ${apiKeyLength}`);
|
||||
log.info(`Using Anthropic API key with prefix '${apiKeyPrefix}...' and length ${apiKeyLength}`);
|
||||
}
|
||||
|
||||
log.info(`Using Anthropic API with model: ${providerOptions.model}`);
|
||||
|
||||
@ -162,8 +164,10 @@ export class AnthropicService extends BaseAIService {
|
||||
// Non-streaming request
|
||||
const response = await client.messages.create(requestParams);
|
||||
|
||||
// Log the complete response for debugging
|
||||
log.info(`[DEBUG] Complete Anthropic API response: ${JSON.stringify(response, null, 2)}`);
|
||||
// Log the complete response only in debug mode
|
||||
if (process.env.LLM_DEBUG === 'true') {
|
||||
log.info(`Complete Anthropic API response: ${JSON.stringify(response, null, 2)}`);
|
||||
}
|
||||
|
||||
// Get the assistant's response text from the content blocks
|
||||
const textContent = response.content
|
||||
@ -180,7 +184,9 @@ export class AnthropicService extends BaseAIService {
|
||||
);
|
||||
|
||||
if (toolBlocks.length > 0) {
|
||||
log.info(`[DEBUG] Found ${toolBlocks.length} tool-related blocks in response`);
|
||||
if (process.env.LLM_DEBUG === 'true') {
|
||||
log.info(`Found ${toolBlocks.length} tool-related blocks in response`);
|
||||
}
|
||||
|
||||
// Use ToolFormatAdapter to convert from Anthropic format
|
||||
toolCalls = ToolFormatAdapter.convertToolCallsFromProvider(
|
||||
|
||||
@ -154,18 +154,22 @@ export class ToolRegistry {
|
||||
const validTools = this.getAllTools();
|
||||
const toolDefs = validTools.map(handler => handler.definition);
|
||||
|
||||
// Enhanced debugging for tool recognition issues
|
||||
log.info(`========== TOOL REGISTRY DEBUG INFO ==========`);
|
||||
// Enhanced debugging for tool recognition issues (only in debug mode)
|
||||
if (process.env.LLM_DEBUG === 'true') {
|
||||
log.info(`========== TOOL REGISTRY INFO ==========`);
|
||||
log.info(`Total tools in registry: ${this.tools.size}`);
|
||||
log.info(`Valid tools after validation: ${validTools.length}`);
|
||||
log.info(`Tool definitions being sent to LLM: ${toolDefs.length}`);
|
||||
}
|
||||
|
||||
// Log each tool for debugging
|
||||
// Log each tool for debugging (only in debug mode)
|
||||
if (process.env.LLM_DEBUG === 'true') {
|
||||
toolDefs.forEach((def, idx) => {
|
||||
log.info(`Tool ${idx + 1}: ${def.function.name} - ${def.function.description?.substring(0, 100) || 'No description'}...`);
|
||||
log.info(` Parameters: ${Object.keys(def.function.parameters?.properties || {}).join(', ') || 'none'}`);
|
||||
log.info(` Required: ${def.function.parameters?.required?.join(', ') || 'none'}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (toolDefs.length === 0) {
|
||||
log.error(`CRITICAL: No tool definitions available for LLM! This will prevent tool calling.`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user