feat(llm): get rid of now unused files

This commit is contained in:
perfectra1n 2025-08-08 22:35:36 -07:00
parent 3db145b6e6
commit a1e596b81b
22 changed files with 189 additions and 6457 deletions

View File

@ -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');
});
});
});

View File

@ -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;

View 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."

View File

@ -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();

View File

@ -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
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();
});
});
});

View File

@ -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);
}
}
}
}
}
}

View File

@ -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>;
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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');
}
}

View File

@ -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 };
}
}

View File

@ -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';
}
}

View File

@ -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;
}
}
}

View File

@ -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 };
}
}

View File

@ -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: "" };
}
}

View File

@ -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;
}
}

View File

@ -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)}`);
}
}

View File

@ -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(

View File

@ -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.`);