feat(llm): add additional logic for tools

This commit is contained in:
perfectra1n 2025-08-09 09:54:55 -07:00
parent 97ec882528
commit f89c202fcc
18 changed files with 5025 additions and 773 deletions

View File

@ -0,0 +1,511 @@
/**
* Enhanced Tool Integration
*
* Integrates tool preview, feedback, and error recovery into the LLM chat experience.
*/
import server from "../../services/server.js";
import { ToolPreviewUI, type ExecutionPlanData, type UserApproval } from "./tool_preview_ui.js";
import { ToolFeedbackUI, type ToolProgressData, type ToolStepData } from "./tool_feedback_ui.js";
/**
* Enhanced tool integration configuration
*/
export interface EnhancedToolConfig {
enablePreview?: boolean;
enableFeedback?: boolean;
enableErrorRecovery?: boolean;
requireConfirmation?: boolean;
autoApproveTimeout?: number;
showHistory?: boolean;
showStatistics?: boolean;
}
/**
* Default configuration
*/
const DEFAULT_CONFIG: EnhancedToolConfig = {
enablePreview: true,
enableFeedback: true,
enableErrorRecovery: true,
requireConfirmation: true,
autoApproveTimeout: 30000, // 30 seconds
showHistory: true,
showStatistics: true
};
/**
* Enhanced Tool Integration Manager
*/
export class EnhancedToolIntegration {
private config: EnhancedToolConfig;
private previewUI?: ToolPreviewUI;
private feedbackUI?: ToolFeedbackUI;
private container: HTMLElement;
private eventHandlers: Map<string, Function[]> = new Map();
private activeExecutions: Set<string> = new Set();
constructor(container: HTMLElement, config?: Partial<EnhancedToolConfig>) {
this.container = container;
this.config = { ...DEFAULT_CONFIG, ...config };
this.initialize();
}
/**
* Initialize the integration
*/
private initialize(): void {
// Create UI containers
this.createUIContainers();
// Initialize UI components
if (this.config.enablePreview) {
const previewContainer = this.container.querySelector('.tool-preview-area') as HTMLElement;
if (previewContainer) {
this.previewUI = new ToolPreviewUI(previewContainer);
}
}
if (this.config.enableFeedback) {
const feedbackContainer = this.container.querySelector('.tool-feedback-area') as HTMLElement;
if (feedbackContainer) {
this.feedbackUI = new ToolFeedbackUI(feedbackContainer);
// Set up history and stats containers if enabled
if (this.config.showHistory) {
const historyContainer = this.container.querySelector('.tool-history-area') as HTMLElement;
if (historyContainer) {
this.feedbackUI.setHistoryContainer(historyContainer);
}
}
if (this.config.showStatistics) {
const statsContainer = this.container.querySelector('.tool-stats-area') as HTMLElement;
if (statsContainer) {
this.feedbackUI.setStatsContainer(statsContainer);
this.loadStatistics();
}
}
}
}
// Load initial data
this.loadActiveExecutions();
this.loadCircuitBreakerStatus();
}
/**
* Create UI containers
*/
private createUIContainers(): void {
// Add enhanced tool UI areas if they don't exist
if (!this.container.querySelector('.tool-preview-area')) {
const previewArea = document.createElement('div');
previewArea.className = 'tool-preview-area mb-3';
this.container.appendChild(previewArea);
}
if (!this.container.querySelector('.tool-feedback-area')) {
const feedbackArea = document.createElement('div');
feedbackArea.className = 'tool-feedback-area mb-3';
this.container.appendChild(feedbackArea);
}
if (this.config.showHistory && !this.container.querySelector('.tool-history-area')) {
const historySection = document.createElement('div');
historySection.className = 'tool-history-section mt-3';
historySection.innerHTML = `
<details class="small">
<summary class="text-muted cursor-pointer">
<i class="bx bx-history me-1"></i>
Execution History
</summary>
<div class="tool-history-area mt-2"></div>
</details>
`;
this.container.appendChild(historySection);
}
if (this.config.showStatistics && !this.container.querySelector('.tool-stats-area')) {
const statsSection = document.createElement('div');
statsSection.className = 'tool-stats-section mt-3';
statsSection.innerHTML = `
<details class="small">
<summary class="text-muted cursor-pointer">
<i class="bx bx-bar-chart me-1"></i>
Tool Statistics
</summary>
<div class="tool-stats-area mt-2"></div>
</details>
`;
this.container.appendChild(statsSection);
}
}
/**
* Handle tool preview request
*/
public async handleToolPreview(toolCalls: any[]): Promise<UserApproval | null> {
if (!this.config.enablePreview || !this.previewUI) {
// Auto-approve if preview is disabled
return {
planId: `auto-${Date.now()}`,
approved: true
};
}
try {
// Get preview from server
const response = await server.post<ExecutionPlanData>('api/llm-tools/preview', {
toolCalls
});
if (!response) {
console.error('Failed to get tool preview');
return null;
}
// Show preview and wait for user approval
return new Promise((resolve) => {
let timeoutId: number | undefined;
const handleApproval = (approval: UserApproval) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
// Send approval to server
server.post(`api/llm-tools/preview/${approval.planId}/approval`, approval)
.catch(error => console.error('Failed to record approval:', error));
resolve(approval);
};
// Show preview UI
this.previewUI!.showPreview(response, handleApproval);
// Auto-approve after timeout if configured
if (this.config.autoApproveTimeout && response.requiresConfirmation) {
timeoutId = window.setTimeout(() => {
const autoApproval: UserApproval = {
planId: response.id,
approved: true
};
handleApproval(autoApproval);
}, this.config.autoApproveTimeout);
}
});
} catch (error) {
console.error('Error handling tool preview:', error);
return null;
}
}
/**
* Start tool execution tracking
*/
public startToolExecution(
executionId: string,
toolName: string,
displayName?: string
): void {
if (!this.config.enableFeedback || !this.feedbackUI) {
return;
}
this.activeExecutions.add(executionId);
this.feedbackUI.startExecution(executionId, toolName, displayName);
}
/**
* Update tool execution progress
*/
public updateToolProgress(data: ToolProgressData): void {
if (!this.config.enableFeedback || !this.feedbackUI) {
return;
}
this.feedbackUI.updateProgress(data);
}
/**
* Add tool execution step
*/
public addToolStep(data: ToolStepData): void {
if (!this.config.enableFeedback || !this.feedbackUI) {
return;
}
this.feedbackUI.addStep(data);
}
/**
* Complete tool execution
*/
public completeToolExecution(
executionId: string,
status: 'success' | 'error' | 'cancelled' | 'timeout',
result?: any,
error?: string
): void {
if (!this.config.enableFeedback || !this.feedbackUI) {
return;
}
this.activeExecutions.delete(executionId);
this.feedbackUI.completeExecution(executionId, status, result, error);
// Refresh statistics
if (this.config.showStatistics) {
setTimeout(() => this.loadStatistics(), 1000);
}
}
/**
* Cancel tool execution
*/
public async cancelToolExecution(executionId: string, reason?: string): Promise<boolean> {
try {
const response = await server.post<any>(`api/llm-tools/executions/${executionId}/cancel`, {
reason
});
if (response?.success) {
this.completeToolExecution(executionId, 'cancelled', undefined, reason);
return true;
}
} catch (error) {
console.error('Failed to cancel execution:', error);
}
return false;
}
/**
* Load active executions
*/
private async loadActiveExecutions(): Promise<void> {
if (!this.config.enableFeedback) {
return;
}
try {
const executions = await server.get<any[]>('api/llm-tools/executions/active');
if (executions && Array.isArray(executions)) {
executions.forEach(exec => {
if (!this.activeExecutions.has(exec.id)) {
this.startToolExecution(exec.id, exec.toolName);
// Restore progress if available
if (exec.progress) {
this.updateToolProgress({
executionId: exec.id,
...exec.progress
});
}
}
});
}
} catch (error) {
console.error('Failed to load active executions:', error);
}
}
/**
* Load execution statistics
*/
private async loadStatistics(): Promise<void> {
if (!this.config.showStatistics) {
return;
}
try {
const stats = await server.get<any>('api/llm-tools/executions/stats');
if (stats) {
this.displayStatistics(stats);
}
} catch (error) {
console.error('Failed to load statistics:', error);
}
}
/**
* Display statistics
*/
private displayStatistics(stats: any): void {
const container = this.container.querySelector('.tool-stats-area') as HTMLElement;
if (!container) return;
container.innerHTML = `
<div class="tool-stats-container">
<div class="tool-stat-item">
<div class="tool-stat-value">${stats.totalExecutions}</div>
<div class="tool-stat-label">Total</div>
</div>
<div class="tool-stat-item">
<div class="tool-stat-value text-success">${stats.successfulExecutions}</div>
<div class="tool-stat-label">Success</div>
</div>
<div class="tool-stat-item">
<div class="tool-stat-value text-danger">${stats.failedExecutions}</div>
<div class="tool-stat-label">Failed</div>
</div>
<div class="tool-stat-item">
<div class="tool-stat-value">${this.formatDuration(stats.averageDuration)}</div>
<div class="tool-stat-label">Avg Time</div>
</div>
</div>
`;
// Add tool-specific statistics if available
if (stats.toolStatistics && Object.keys(stats.toolStatistics).length > 0) {
const toolStatsHtml = Object.entries(stats.toolStatistics)
.map(([toolName, toolStats]: [string, any]) => `
<tr>
<td>${toolName}</td>
<td>${toolStats.count}</td>
<td>${toolStats.successRate}%</td>
<td>${this.formatDuration(toolStats.averageDuration)}</td>
</tr>
`).join('');
container.innerHTML += `
<div class="mt-3">
<h6 class="small text-muted">Per-Tool Statistics</h6>
<table class="table table-sm small">
<thead>
<tr>
<th>Tool</th>
<th>Count</th>
<th>Success</th>
<th>Avg Time</th>
</tr>
</thead>
<tbody>
${toolStatsHtml}
</tbody>
</table>
</div>
`;
}
}
/**
* Load circuit breaker status
*/
private async loadCircuitBreakerStatus(): Promise<void> {
try {
const statuses = await server.get<any[]>('api/llm-tools/circuit-breakers');
if (statuses && Array.isArray(statuses)) {
this.displayCircuitBreakerStatus(statuses);
}
} catch (error) {
console.error('Failed to load circuit breaker status:', error);
}
}
/**
* Display circuit breaker status
*/
private displayCircuitBreakerStatus(statuses: any[]): void {
const openBreakers = statuses.filter(s => s.state === 'open');
const halfOpenBreakers = statuses.filter(s => s.state === 'half_open');
if (openBreakers.length > 0 || halfOpenBreakers.length > 0) {
const alertContainer = document.createElement('div');
alertContainer.className = 'circuit-breaker-alerts mb-3';
if (openBreakers.length > 0) {
alertContainer.innerHTML += `
<div class="alert alert-danger small py-2">
<i class="bx bx-error-circle me-1"></i>
<strong>Circuit Breakers Open:</strong>
${openBreakers.map(b => b.toolName).join(', ')}
<button class="btn btn-sm btn-link reset-breakers-btn float-end py-0">
Reset All
</button>
</div>
`;
}
if (halfOpenBreakers.length > 0) {
alertContainer.innerHTML += `
<div class="alert alert-warning small py-2">
<i class="bx bx-error me-1"></i>
<strong>Circuit Breakers Half-Open:</strong>
${halfOpenBreakers.map(b => b.toolName).join(', ')}
</div>
`;
}
// Add to container
const existingAlerts = this.container.querySelector('.circuit-breaker-alerts');
if (existingAlerts) {
existingAlerts.replaceWith(alertContainer);
} else {
this.container.insertBefore(alertContainer, this.container.firstChild);
}
// Add reset handler
const resetBtn = alertContainer.querySelector('.reset-breakers-btn');
resetBtn?.addEventListener('click', () => this.resetAllCircuitBreakers(openBreakers));
}
}
/**
* Reset all circuit breakers
*/
private async resetAllCircuitBreakers(breakers: any[]): Promise<void> {
for (const breaker of breakers) {
try {
await server.post(`api/llm-tools/circuit-breakers/${breaker.toolName}/reset`, {});
} catch (error) {
console.error(`Failed to reset circuit breaker for ${breaker.toolName}:`, error);
}
}
// Reload status
this.loadCircuitBreakerStatus();
}
/**
* Format duration
*/
private formatDuration(milliseconds: number): string {
if (!milliseconds || milliseconds === 0) return '0ms';
if (milliseconds < 1000) {
return `${Math.round(milliseconds)}ms`;
} else if (milliseconds < 60000) {
return `${(milliseconds / 1000).toFixed(1)}s`;
} else {
const minutes = Math.floor(milliseconds / 60000);
const seconds = Math.floor((milliseconds % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
}
/**
* Clean up resources
*/
public dispose(): void {
this.eventHandlers.clear();
this.activeExecutions.clear();
if (this.feedbackUI) {
this.feedbackUI.clear();
}
}
}
/**
* Create enhanced tool integration
*/
export function createEnhancedToolIntegration(
container: HTMLElement,
config?: Partial<EnhancedToolConfig>
): EnhancedToolIntegration {
return new EnhancedToolIntegration(container, config);
}

View File

@ -0,0 +1,333 @@
/**
* Enhanced Tool UI Styles
* Styles for tool preview, feedback, and error recovery UI components
*/
/* Tool Preview Styles */
.tool-preview-container {
animation: slideIn 0.3s ease-out;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.tool-preview-container.fade-out {
animation: fadeOut 0.3s ease-out;
opacity: 0;
}
.tool-preview-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding-bottom: 0.75rem;
}
.tool-preview-item {
transition: all 0.2s ease;
cursor: pointer;
}
.tool-preview-item:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.tool-preview-item input[type="checkbox"] {
cursor: pointer;
}
.tool-preview-item .parameter-item {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
}
.tool-preview-item .parameter-key {
font-weight: 600;
}
.tool-preview-item details summary {
user-select: none;
cursor: pointer;
}
.tool-preview-item details summary:hover {
text-decoration: underline;
}
.tool-preview-actions button {
min-width: 100px;
}
/* Tool Feedback Styles */
.tool-execution-feedback {
animation: slideIn 0.3s ease-out;
transition: all 0.3s ease;
}
.tool-execution-feedback.fade-out {
animation: fadeOut 0.3s ease-out;
opacity: 0;
}
.tool-execution-feedback.border-success {
border-color: var(--bs-success) !important;
background-color: rgba(25, 135, 84, 0.05) !important;
}
.tool-execution-feedback.border-danger {
border-color: var(--bs-danger) !important;
background-color: rgba(220, 53, 69, 0.05) !important;
}
.tool-execution-feedback.border-warning {
border-color: var(--bs-warning) !important;
background-color: rgba(255, 193, 7, 0.05) !important;
}
.tool-execution-feedback .progress {
background-color: rgba(0, 0, 0, 0.05);
}
.tool-execution-feedback .progress-bar {
transition: width 0.3s ease;
}
.tool-execution-feedback .tool-steps {
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding-top: 0.5rem;
margin-top: 0.5rem;
}
.tool-execution-feedback .tool-step {
padding: 2px 4px;
border-radius: 3px;
font-size: 0.8rem;
line-height: 1.4;
}
.tool-execution-feedback .tool-step.tool-step-error {
background-color: rgba(220, 53, 69, 0.1);
}
.tool-execution-feedback .tool-step.tool-step-warning {
background-color: rgba(255, 193, 7, 0.1);
}
.tool-execution-feedback .tool-step.tool-step-progress {
background-color: rgba(13, 110, 253, 0.1);
}
.tool-execution-feedback .cancel-btn {
opacity: 0.6;
transition: opacity 0.2s ease;
}
.tool-execution-feedback .cancel-btn:hover {
opacity: 1;
}
/* Real-time Progress Indicator */
.tool-progress-realtime {
position: relative;
overflow: hidden;
}
.tool-progress-realtime::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
animation: shimmer 2s infinite;
}
/* Tool Execution History */
.tool-history-container {
max-height: 200px;
overflow-y: auto;
padding: 0.5rem;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 4px;
}
.tool-history-container .history-item {
padding: 2px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.tool-history-container .history-item:last-child {
border-bottom: none;
}
/* Tool Statistics */
.tool-stats-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
padding: 1rem;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 4px;
}
.tool-stat-item {
text-align: center;
}
.tool-stat-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--bs-primary);
}
.tool-stat-label {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--bs-secondary);
}
/* Error Recovery UI */
.tool-error-recovery {
background-color: rgba(220, 53, 69, 0.05);
border: 1px solid var(--bs-danger);
border-radius: 4px;
padding: 1rem;
margin: 0.5rem 0;
}
.tool-error-recovery .error-message {
font-weight: 500;
margin-bottom: 0.5rem;
}
.tool-error-recovery .error-suggestions {
list-style: none;
padding: 0;
margin: 0.5rem 0;
}
.tool-error-recovery .error-suggestions li {
padding: 0.25rem 0;
padding-left: 1.5rem;
position: relative;
}
.tool-error-recovery .error-suggestions li::before {
content: '→';
position: absolute;
left: 0;
color: var(--bs-warning);
}
.tool-recovery-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.tool-recovery-actions button {
font-size: 0.85rem;
}
/* Circuit Breaker Indicator */
.circuit-breaker-status {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
.circuit-breaker-status.status-closed {
background-color: rgba(25, 135, 84, 0.1);
color: var(--bs-success);
}
.circuit-breaker-status.status-open {
background-color: rgba(220, 53, 69, 0.1);
color: var(--bs-danger);
}
.circuit-breaker-status.status-half-open {
background-color: rgba(255, 193, 7, 0.1);
color: var(--bs-warning);
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes shimmer {
to {
left: 100%;
}
}
/* Spinner Override for Tool Execution */
.tool-execution-feedback .spinner-border-sm {
width: 1rem;
height: 1rem;
border-width: 0.15em;
}
/* Responsive Design */
@media (max-width: 768px) {
.tool-preview-container {
padding: 0.75rem;
}
.tool-preview-actions {
flex-direction: column;
}
.tool-preview-actions button {
width: 100%;
}
.tool-stats-container {
grid-template-columns: 1fr;
}
}
/* Dark Mode Support */
@media (prefers-color-scheme: dark) {
.tool-preview-container,
.tool-execution-feedback {
background-color: rgba(255, 255, 255, 0.05) !important;
color: #e0e0e0;
}
.tool-preview-item {
background-color: rgba(255, 255, 255, 0.03) !important;
}
.tool-history-container,
.tool-stats-container {
background-color: rgba(255, 255, 255, 0.02);
}
.parameter-item {
background-color: rgba(0, 0, 0, 0.2);
}
}

View File

@ -0,0 +1,599 @@
/**
* Tool Feedback UI Component
*
* Provides real-time feedback UI during tool execution including
* progress tracking, step visualization, and execution history.
*/
import { t } from "../../services/i18n.js";
import { VirtualScrollManager, createVirtualScroll } from './virtual_scroll.js';
// UI Constants
const UI_CONSTANTS = {
HISTORY_MOVE_DELAY: 5000,
STEP_COLLAPSE_DELAY: 1000,
FADE_OUT_DURATION: 300,
MAX_HISTORY_UI_SIZE: 50,
MAX_VISIBLE_STEPS: 3,
MAX_STRING_DISPLAY_LENGTH: 100,
MAX_STEP_CONTAINER_HEIGHT: 150,
} as const;
/**
* Tool execution status
*/
export type ToolExecutionStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled' | 'timeout';
/**
* Tool execution progress data
*/
export interface ToolProgressData {
executionId: string;
current: number;
total: number;
percentage: number;
message?: string;
estimatedTimeRemaining?: number;
}
/**
* Tool execution step data
*/
export interface ToolStepData {
executionId: string;
timestamp: string;
message: string;
type: 'info' | 'warning' | 'error' | 'progress';
data?: any;
}
/**
* Tool execution tracker
*/
interface ExecutionTracker {
id: string;
toolName: string;
element: HTMLElement;
startTime: number;
status: ToolExecutionStatus;
steps: ToolStepData[];
animationFrameId?: number;
}
/**
* Tool Feedback UI Manager
*/
export class ToolFeedbackUI {
private container: HTMLElement;
private executions: Map<string, ExecutionTracker> = new Map();
private historyContainer?: HTMLElement;
private statsContainer?: HTMLElement;
private virtualScroll?: VirtualScrollManager;
private historyItems: any[] = [];
constructor(container: HTMLElement) {
this.container = container;
}
/**
* Start tracking a tool execution
*/
public startExecution(
executionId: string,
toolName: string,
displayName?: string
): void {
// Create execution element
const element = this.createExecutionElement(executionId, toolName, displayName);
this.container.appendChild(element);
// Create tracker
const tracker: ExecutionTracker = {
id: executionId,
toolName,
element,
startTime: Date.now(),
status: 'running',
steps: []
};
// Start elapsed time update with requestAnimationFrame
this.startElapsedTimeAnimation(tracker);
this.executions.set(executionId, tracker);
// Auto-scroll to new execution
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
/**
* Update execution progress
*/
public updateProgress(data: ToolProgressData): void {
const tracker = this.executions.get(data.executionId);
if (!tracker) return;
const progressBar = tracker.element.querySelector('.progress-bar') as HTMLElement;
const progressText = tracker.element.querySelector('.progress-text') as HTMLElement;
const progressContainer = tracker.element.querySelector('.tool-progress') as HTMLElement;
if (progressContainer) {
progressContainer.style.display = 'block';
}
if (progressBar) {
progressBar.style.width = `${data.percentage}%`;
progressBar.setAttribute('aria-valuenow', String(data.percentage));
}
if (progressText) {
let text = `${data.current}/${data.total}`;
if (data.message) {
text += ` - ${data.message}`;
}
if (data.estimatedTimeRemaining) {
text += ` (${this.formatDuration(data.estimatedTimeRemaining)} remaining)`;
}
progressText.textContent = text;
}
}
/**
* Add execution step
*/
public addStep(data: ToolStepData): void {
const tracker = this.executions.get(data.executionId);
if (!tracker) return;
tracker.steps.push(data);
const stepsContainer = tracker.element.querySelector('.tool-steps') as HTMLElement;
if (stepsContainer) {
const stepElement = this.createStepElement(data);
stepsContainer.appendChild(stepElement);
// Show steps container if hidden
stepsContainer.style.display = 'block';
// Auto-scroll steps
stepsContainer.scrollTop = stepsContainer.scrollHeight;
}
// Update status indicator for warnings/errors
if (data.type === 'warning' || data.type === 'error') {
this.updateStatusIndicator(tracker, data.type);
}
}
/**
* Complete execution
*/
public completeExecution(
executionId: string,
status: 'success' | 'error' | 'cancelled' | 'timeout',
result?: any,
error?: string
): void {
const tracker = this.executions.get(executionId);
if (!tracker) return;
tracker.status = status;
// Stop elapsed time update
if (tracker.animationFrameId) {
cancelAnimationFrame(tracker.animationFrameId);
tracker.animationFrameId = undefined;
}
// Update UI
this.updateStatusIndicator(tracker, status);
const duration = Date.now() - tracker.startTime;
const durationElement = tracker.element.querySelector('.tool-duration') as HTMLElement;
if (durationElement) {
durationElement.textContent = this.formatDuration(duration);
}
// Show result or error
if (status === 'success' && result) {
const resultElement = tracker.element.querySelector('.tool-result') as HTMLElement;
if (resultElement) {
resultElement.style.display = 'block';
resultElement.textContent = this.formatResult(result);
}
} else if ((status === 'error' || status === 'timeout') && error) {
const errorElement = tracker.element.querySelector('.tool-error') as HTMLElement;
if (errorElement) {
errorElement.style.display = 'block';
errorElement.textContent = error;
}
}
// Collapse steps after completion
setTimeout(() => {
this.collapseStepsIfNeeded(tracker);
}, UI_CONSTANTS.STEP_COLLAPSE_DELAY);
// Move to history after a delay
setTimeout(() => {
this.moveToHistory(tracker);
}, UI_CONSTANTS.HISTORY_MOVE_DELAY);
}
/**
* Cancel execution
*/
public cancelExecution(executionId: string): void {
this.completeExecution(executionId, 'cancelled', undefined, 'Cancelled by user');
}
/**
* Create execution element
*/
private createExecutionElement(
executionId: string,
toolName: string,
displayName?: string
): HTMLElement {
const element = document.createElement('div');
element.className = 'tool-execution-feedback mb-2 p-2 border rounded bg-light';
element.dataset.executionId = executionId;
element.innerHTML = `
<div class="d-flex align-items-start">
<div class="tool-status-icon me-2">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Running...</span>
</div>
</div>
<div class="flex-grow-1">
<div class="d-flex align-items-center justify-content-between">
<div class="tool-name fw-bold small">
${displayName || toolName}
</div>
<div class="tool-actions">
<button class="btn btn-sm btn-link p-0 cancel-btn" title="Cancel">
<i class="bx bx-x"></i>
</button>
</div>
</div>
<div class="tool-progress mt-1" style="display: none;">
<div class="progress" style="height: 4px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: 0%"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
<div class="progress-text text-muted small mt-1"></div>
</div>
<div class="tool-steps mt-2 small" style="display: none; max-height: ${UI_CONSTANTS.MAX_STEP_CONTAINER_HEIGHT}px; overflow-y: auto;">
</div>
<div class="tool-result text-success small mt-2" style="display: none;"></div>
<div class="tool-error text-danger small mt-2" style="display: none;"></div>
</div>
<div class="tool-duration text-muted small ms-2">
<span class="elapsed-time">0s</span>
</div>
</div>
`;
// Add cancel button listener
const cancelBtn = element.querySelector('.cancel-btn') as HTMLButtonElement;
cancelBtn?.addEventListener('click', () => {
this.cancelExecution(executionId);
});
return element;
}
/**
* Create step element
*/
private createStepElement(step: ToolStepData): HTMLElement {
const element = document.createElement('div');
element.className = `tool-step tool-step-${step.type} text-${this.getStepColor(step.type)} mb-1`;
const timestamp = new Date(step.timestamp).toLocaleTimeString();
element.innerHTML = `
<i class="bx ${this.getStepIcon(step.type)} me-1"></i>
<span class="step-time text-muted">[${timestamp}]</span>
<span class="step-message ms-1">${step.message}</span>
`;
return element;
}
/**
* Update status indicator
*/
private updateStatusIndicator(tracker: ExecutionTracker, status: string): void {
const statusIcon = tracker.element.querySelector('.tool-status-icon');
if (!statusIcon) return;
const icons: Record<string, string> = {
'success': '<i class="bx bx-check-circle text-success fs-5"></i>',
'error': '<i class="bx bx-error-circle text-danger fs-5"></i>',
'warning': '<i class="bx bx-error text-warning fs-5"></i>',
'cancelled': '<i class="bx bx-x-circle text-warning fs-5"></i>',
'timeout': '<i class="bx bx-time-five text-danger fs-5"></i>'
};
if (icons[status]) {
statusIcon.innerHTML = icons[status];
}
// Update container style
const borderColors: Record<string, string> = {
'success': 'border-success',
'error': 'border-danger',
'warning': 'border-warning',
'cancelled': 'border-warning',
'timeout': 'border-danger'
};
if (borderColors[status]) {
tracker.element.classList.add(borderColors[status]);
}
}
/**
* Start elapsed time animation with requestAnimationFrame
*/
private startElapsedTimeAnimation(tracker: ExecutionTracker): void {
const updateTime = () => {
if (this.executions.has(tracker.id)) {
const elapsed = Date.now() - tracker.startTime;
const elapsedElement = tracker.element.querySelector('.elapsed-time') as HTMLElement;
if (elapsedElement) {
elapsedElement.textContent = this.formatDuration(elapsed);
}
tracker.animationFrameId = requestAnimationFrame(updateTime);
}
};
tracker.animationFrameId = requestAnimationFrame(updateTime);
}
/**
* Move execution to history
*/
private moveToHistory(tracker: ExecutionTracker): void {
// Remove from active executions
this.executions.delete(tracker.id);
// Fade out and remove
tracker.element.classList.add('fade-out');
setTimeout(() => {
tracker.element.remove();
}, UI_CONSTANTS.FADE_OUT_DURATION);
// Add to history
this.addToHistory(tracker);
}
/**
* Add tracker to history
*/
private addToHistory(tracker: ExecutionTracker): void {
// Add to history items array
this.historyItems.unshift(tracker);
// Limit history size
if (this.historyItems.length > UI_CONSTANTS.MAX_HISTORY_UI_SIZE) {
this.historyItems = this.historyItems.slice(0, UI_CONSTANTS.MAX_HISTORY_UI_SIZE);
}
// Update display
if (this.virtualScroll) {
this.virtualScroll.updateTotalItems(this.historyItems.length);
this.virtualScroll.refresh();
} else if (this.historyContainer) {
const historyItem = this.createHistoryItem(tracker);
this.historyContainer.prepend(historyItem);
// Limit DOM elements
const elements = this.historyContainer.querySelectorAll('.history-item');
if (elements.length > UI_CONSTANTS.MAX_HISTORY_UI_SIZE) {
elements[elements.length - 1].remove();
}
}
}
/**
* Create history item
*/
private createHistoryItem(tracker: ExecutionTracker): HTMLElement {
const element = document.createElement('div');
element.className = 'history-item small text-muted mb-1';
const duration = Date.now() - tracker.startTime;
const statusIcon = this.getStatusIcon(tracker.status);
const time = new Date(tracker.startTime).toLocaleTimeString();
element.innerHTML = `
${statusIcon}
<span class="ms-1">${tracker.toolName}</span>
<span class="ms-1">(${this.formatDuration(duration)})</span>
<span class="ms-1 text-muted">${time}</span>
`;
return element;
}
/**
* Get step color
*/
private getStepColor(type: string): string {
const colors: Record<string, string> = {
'info': 'muted',
'warning': 'warning',
'error': 'danger',
'progress': 'primary'
};
return colors[type] || 'muted';
}
/**
* Get step icon
*/
private getStepIcon(type: string): string {
const icons: Record<string, string> = {
'info': 'bx-info-circle',
'warning': 'bx-error',
'error': 'bx-error-circle',
'progress': 'bx-loader-alt'
};
return icons[type] || 'bx-circle';
}
/**
* Get status icon
*/
private getStatusIcon(status: string): string {
const icons: Record<string, string> = {
'success': '<i class="bx bx-check-circle text-success"></i>',
'error': '<i class="bx bx-error-circle text-danger"></i>',
'cancelled': '<i class="bx bx-x-circle text-warning"></i>',
'timeout': '<i class="bx bx-time-five text-danger"></i>',
'running': '<i class="bx bx-loader-alt text-primary"></i>',
'pending': '<i class="bx bx-time text-muted"></i>'
};
return icons[status] || '<i class="bx bx-circle text-muted"></i>';
}
/**
* Collapse steps if there are too many
*/
private collapseStepsIfNeeded(tracker: ExecutionTracker): void {
const stepsContainer = tracker.element.querySelector('.tool-steps') as HTMLElement;
if (stepsContainer && tracker.steps.length > UI_CONSTANTS.MAX_VISIBLE_STEPS) {
const details = document.createElement('details');
details.className = 'mt-2';
details.innerHTML = `
<summary class="text-muted small cursor-pointer">
Show ${tracker.steps.length} execution steps
</summary>
`;
details.appendChild(stepsContainer.cloneNode(true));
stepsContainer.replaceWith(details);
}
}
/**
* Format result for display
*/
private formatResult(result: any): string {
if (typeof result === 'string') {
return this.truncateString(result);
}
const json = JSON.stringify(result);
return this.truncateString(json);
}
/**
* Truncate string for display
*/
private truncateString(str: string, maxLength: number = UI_CONSTANTS.MAX_STRING_DISPLAY_LENGTH): string {
if (str.length <= maxLength) {
return str;
}
return `${str.substring(0, maxLength)}...`;
}
/**
* Format duration
*/
private formatDuration(milliseconds: number): string {
if (milliseconds < 1000) {
return `${Math.round(milliseconds)}ms`;
} else if (milliseconds < 60000) {
return `${(milliseconds / 1000).toFixed(1)}s`;
} else {
const minutes = Math.floor(milliseconds / 60000);
const seconds = Math.floor((milliseconds % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
}
/**
* Set history container with virtual scrolling
*/
public setHistoryContainer(container: HTMLElement, useVirtualScroll: boolean = false): void {
this.historyContainer = container;
if (useVirtualScroll && this.historyItems.length > 20) {
this.initializeVirtualScroll();
}
}
/**
* Initialize virtual scrolling for history
*/
private initializeVirtualScroll(): void {
if (!this.historyContainer) return;
this.virtualScroll = createVirtualScroll({
container: this.historyContainer,
itemHeight: 30, // Approximate height of history items
totalItems: this.historyItems.length,
overscan: 3,
onRenderItem: (index) => {
return this.renderHistoryItemAtIndex(index);
}
});
}
/**
* Render history item at specific index
*/
private renderHistoryItemAtIndex(index: number): HTMLElement {
const item = this.historyItems[index];
if (!item) {
const empty = document.createElement('div');
empty.className = 'history-item-empty';
return empty;
}
return this.createHistoryItem(item);
}
/**
* Set statistics container
*/
public setStatsContainer(container: HTMLElement): void {
this.statsContainer = container;
}
/**
* Clear all executions
*/
public clear(): void {
this.executions.forEach(tracker => {
if (tracker.animationFrameId) {
cancelAnimationFrame(tracker.animationFrameId);
}
});
this.executions.clear();
this.container.innerHTML = '';
this.historyItems = [];
if (this.virtualScroll) {
this.virtualScroll.destroy();
this.virtualScroll = undefined;
}
if (this.historyContainer) {
this.historyContainer.innerHTML = '';
}
}
}
/**
* Create a tool feedback UI instance
*/
export function createToolFeedbackUI(container: HTMLElement): ToolFeedbackUI {
return new ToolFeedbackUI(container);
}

View File

@ -0,0 +1,367 @@
/**
* Tool Preview UI Component
*
* Provides UI for previewing tool executions before they run,
* allowing users to approve, reject, or modify tool parameters.
*/
import { t } from "../../services/i18n.js";
/**
* Tool preview data from server
*/
export interface ToolPreviewData {
id: string;
toolName: string;
displayName: string;
description: string;
parameters: Record<string, unknown>;
formattedParameters: string[];
estimatedDuration: number;
riskLevel: 'low' | 'medium' | 'high';
requiresConfirmation: boolean;
warnings?: string[];
}
/**
* Execution plan from server
*/
export interface ExecutionPlanData {
id: string;
tools: ToolPreviewData[];
totalEstimatedDuration: number;
requiresConfirmation: boolean;
createdAt: string;
}
/**
* User approval data
*/
export interface UserApproval {
planId: string;
approved: boolean;
rejectedTools?: string[];
modifiedParameters?: Record<string, Record<string, unknown>>;
}
/**
* Tool Preview UI Manager
*/
export class ToolPreviewUI {
private container: HTMLElement;
private currentPlan?: ExecutionPlanData;
private onApprovalCallback?: (approval: UserApproval) => void;
constructor(container: HTMLElement) {
this.container = container;
}
/**
* Show tool execution preview
*/
public async showPreview(
plan: ExecutionPlanData,
onApproval: (approval: UserApproval) => void
): Promise<void> {
this.currentPlan = plan;
this.onApprovalCallback = onApproval;
const previewElement = this.createPreviewElement(plan);
this.container.appendChild(previewElement);
// Auto-scroll to preview
previewElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
/**
* Create preview element
*/
private createPreviewElement(plan: ExecutionPlanData): HTMLElement {
const element = document.createElement('div');
element.className = 'tool-preview-container mb-3 border rounded p-3 bg-light';
element.dataset.planId = plan.id;
// Header
const header = document.createElement('div');
header.className = 'tool-preview-header mb-3';
header.innerHTML = `
<h6 class="mb-2">
<i class="bx bx-shield-quarter me-2"></i>
${t('Tool Execution Preview')}
</h6>
<p class="text-muted small mb-2">
${plan.tools.length} ${plan.tools.length === 1 ? 'tool' : 'tools'} will be executed
${plan.requiresConfirmation ? ' (confirmation required)' : ''}
</p>
<div class="d-flex align-items-center gap-3 small text-muted">
<span>
<i class="bx bx-time-five me-1"></i>
Estimated time: ${this.formatDuration(plan.totalEstimatedDuration)}
</span>
</div>
`;
element.appendChild(header);
// Tool list
const toolList = document.createElement('div');
toolList.className = 'tool-preview-list mb-3';
plan.tools.forEach((tool, index) => {
const toolElement = this.createToolPreviewItem(tool, index);
toolList.appendChild(toolElement);
});
element.appendChild(toolList);
// Actions
const actions = document.createElement('div');
actions.className = 'tool-preview-actions d-flex gap-2';
if (plan.requiresConfirmation) {
actions.innerHTML = `
<button class="btn btn-success btn-sm approve-all-btn">
<i class="bx bx-check me-1"></i>
Approve All
</button>
<button class="btn btn-warning btn-sm modify-btn">
<i class="bx bx-edit me-1"></i>
Modify
</button>
<button class="btn btn-danger btn-sm reject-all-btn">
<i class="bx bx-x me-1"></i>
Reject All
</button>
`;
// Add event listeners
const approveBtn = actions.querySelector('.approve-all-btn') as HTMLButtonElement;
const modifyBtn = actions.querySelector('.modify-btn') as HTMLButtonElement;
const rejectBtn = actions.querySelector('.reject-all-btn') as HTMLButtonElement;
approveBtn?.addEventListener('click', () => this.handleApproveAll());
modifyBtn?.addEventListener('click', () => this.handleModify());
rejectBtn?.addEventListener('click', () => this.handleRejectAll());
} else {
// Auto-approve after showing preview
setTimeout(() => {
this.handleApproveAll();
}, 500);
}
element.appendChild(actions);
return element;
}
/**
* Create tool preview item
*/
private createToolPreviewItem(tool: ToolPreviewData, index: number): HTMLElement {
const item = document.createElement('div');
item.className = 'tool-preview-item mb-2 p-2 border rounded bg-white';
item.dataset.toolName = tool.toolName;
const riskBadge = this.getRiskBadge(tool.riskLevel);
const riskIcon = this.getRiskIcon(tool.riskLevel);
item.innerHTML = `
<div class="d-flex align-items-start">
<div class="tool-preview-checkbox me-2 pt-1">
<input type="checkbox" class="form-check-input"
id="tool-${index}"
checked
${tool.requiresConfirmation ? '' : 'disabled'}>
</div>
<div class="flex-grow-1">
<div class="d-flex align-items-center mb-1">
<label class="tool-name fw-bold small mb-0" for="tool-${index}">
${tool.displayName}
</label>
${riskBadge}
${riskIcon}
</div>
<div class="tool-description text-muted small mb-2">
${tool.description}
</div>
<div class="tool-parameters small">
<details>
<summary class="text-primary cursor-pointer">
Parameters (${Object.keys(tool.parameters).length})
</summary>
<div class="mt-1 p-2 bg-light rounded">
${this.formatParameters(tool.formattedParameters)}
</div>
</details>
</div>
${tool.warnings && tool.warnings.length > 0 ? `
<div class="tool-warnings mt-2">
${tool.warnings.map(w => `
<div class="alert alert-warning py-1 px-2 mb-1 small">
<i class="bx bx-error-circle me-1"></i>
${w}
</div>
`).join('')}
</div>
` : ''}
</div>
<div class="tool-duration text-muted small ms-2">
<i class="bx bx-time me-1"></i>
~${this.formatDuration(tool.estimatedDuration)}
</div>
</div>
`;
return item;
}
/**
* Get risk level badge
*/
private getRiskBadge(riskLevel: 'low' | 'medium' | 'high'): string {
const badges = {
low: '<span class="badge bg-success ms-2">Low Risk</span>',
medium: '<span class="badge bg-warning ms-2">Medium Risk</span>',
high: '<span class="badge bg-danger ms-2">High Risk</span>'
};
return badges[riskLevel] || '';
}
/**
* Get risk level icon
*/
private getRiskIcon(riskLevel: 'low' | 'medium' | 'high'): string {
const icons = {
low: '<i class="bx bx-shield-check text-success ms-2"></i>',
medium: '<i class="bx bx-shield text-warning ms-2"></i>',
high: '<i class="bx bx-shield-x text-danger ms-2"></i>'
};
return icons[riskLevel] || '';
}
/**
* Format parameters for display
*/
private formatParameters(parameters: string[]): string {
return parameters.map(param => {
const [key, ...valueParts] = param.split(':');
const value = valueParts.join(':').trim();
return `
<div class="parameter-item mb-1">
<span class="parameter-key text-muted">${key}:</span>
<span class="parameter-value ms-1">${this.escapeHtml(value)}</span>
</div>
`;
}).join('');
}
/**
* Handle approve all
*/
private handleApproveAll(): void {
if (!this.currentPlan || !this.onApprovalCallback) return;
const approval: UserApproval = {
planId: this.currentPlan.id,
approved: true
};
this.onApprovalCallback(approval);
this.hidePreview();
}
/**
* Handle modify
*/
private handleModify(): void {
if (!this.currentPlan) return;
// Get selected tools
const checkboxes = this.container.querySelectorAll('.tool-preview-item input[type="checkbox"]');
const rejectedTools: string[] = [];
checkboxes.forEach((checkbox: Element) => {
const input = checkbox as HTMLInputElement;
const toolItem = input.closest('.tool-preview-item') as HTMLElement;
const toolName = toolItem?.dataset.toolName;
if (toolName && !input.checked) {
rejectedTools.push(toolName);
}
});
const approval: UserApproval = {
planId: this.currentPlan.id,
approved: true,
rejectedTools: rejectedTools.length > 0 ? rejectedTools : undefined
};
if (this.onApprovalCallback) {
this.onApprovalCallback(approval);
}
this.hidePreview();
}
/**
* Handle reject all
*/
private handleRejectAll(): void {
if (!this.currentPlan || !this.onApprovalCallback) return;
const approval: UserApproval = {
planId: this.currentPlan.id,
approved: false
};
this.onApprovalCallback(approval);
this.hidePreview();
}
/**
* Hide preview
*/
private hidePreview(): void {
const preview = this.container.querySelector('.tool-preview-container');
if (preview) {
// Add fade out animation
preview.classList.add('fade-out');
setTimeout(() => {
preview.remove();
}, 300);
}
this.currentPlan = undefined;
this.onApprovalCallback = undefined;
}
/**
* Format duration
*/
private formatDuration(milliseconds: number): string {
if (milliseconds < 1000) {
return `${milliseconds}ms`;
} else if (milliseconds < 60000) {
return `${(milliseconds / 1000).toFixed(1)}s`;
} else {
const minutes = Math.floor(milliseconds / 60000);
const seconds = Math.floor((milliseconds % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
}
/**
* Escape HTML
*/
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
/**
* Create a tool preview UI instance
*/
export function createToolPreviewUI(container: HTMLElement): ToolPreviewUI {
return new ToolPreviewUI(container);
}

View File

@ -0,0 +1,419 @@
/**
* Tool WebSocket Manager
*
* Provides real-time WebSocket communication for tool execution updates.
* Implements automatic reconnection, heartbeat, and message queuing.
*/
import { EventEmitter } from 'events';
/**
* WebSocket message types
*/
export enum WSMessageType {
// Tool execution events
TOOL_START = 'tool:start',
TOOL_PROGRESS = 'tool:progress',
TOOL_STEP = 'tool:step',
TOOL_COMPLETE = 'tool:complete',
TOOL_ERROR = 'tool:error',
TOOL_CANCELLED = 'tool:cancelled',
// Connection events
HEARTBEAT = 'heartbeat',
PING = 'ping',
PONG = 'pong',
// Control events
SUBSCRIBE = 'subscribe',
UNSUBSCRIBE = 'unsubscribe',
}
/**
* WebSocket message structure
*/
export interface WSMessage {
id: string;
type: WSMessageType;
timestamp: string;
data: any;
}
/**
* WebSocket configuration
*/
export interface WSConfig {
url: string;
reconnectInterval?: number;
maxReconnectAttempts?: number;
heartbeatInterval?: number;
messageTimeout?: number;
autoReconnect?: boolean;
}
/**
* Connection state
*/
export enum ConnectionState {
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
DISCONNECTED = 'disconnected',
FAILED = 'failed'
}
/**
* Tool WebSocket Manager
*/
export class ToolWebSocketManager extends EventEmitter {
private ws?: WebSocket;
private config: Required<WSConfig>;
private state: ConnectionState = ConnectionState.DISCONNECTED;
private reconnectAttempts: number = 0;
private reconnectTimer?: number;
private heartbeatTimer?: number;
private messageQueue: WSMessage[] = [];
private subscriptions: Set<string> = new Set();
private lastPingTime?: number;
private lastPongTime?: number;
// Performance constants
private static readonly DEFAULT_RECONNECT_INTERVAL = 3000;
private static readonly DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;
private static readonly DEFAULT_HEARTBEAT_INTERVAL = 30000;
private static readonly DEFAULT_MESSAGE_TIMEOUT = 5000;
private static readonly MAX_QUEUE_SIZE = 100;
constructor(config: WSConfig) {
super();
this.config = {
url: config.url,
reconnectInterval: config.reconnectInterval ?? ToolWebSocketManager.DEFAULT_RECONNECT_INTERVAL,
maxReconnectAttempts: config.maxReconnectAttempts ?? ToolWebSocketManager.DEFAULT_MAX_RECONNECT_ATTEMPTS,
heartbeatInterval: config.heartbeatInterval ?? ToolWebSocketManager.DEFAULT_HEARTBEAT_INTERVAL,
messageTimeout: config.messageTimeout ?? ToolWebSocketManager.DEFAULT_MESSAGE_TIMEOUT,
autoReconnect: config.autoReconnect ?? true
};
}
/**
* Connect to WebSocket server
*/
public connect(): void {
if (this.state === ConnectionState.CONNECTED || this.state === ConnectionState.CONNECTING) {
return;
}
this.state = ConnectionState.CONNECTING;
this.emit('connecting');
try {
this.ws = new WebSocket(this.config.url);
this.setupEventHandlers();
} catch (error) {
this.handleConnectionError(error);
}
}
/**
* Setup WebSocket event handlers
*/
private setupEventHandlers(): void {
if (!this.ws) return;
this.ws.onopen = () => {
this.state = ConnectionState.CONNECTED;
this.reconnectAttempts = 0;
this.emit('connected');
// Start heartbeat
this.startHeartbeat();
// Re-subscribe to previous subscriptions
this.resubscribe();
// Flush message queue
this.flushMessageQueue();
};
this.ws.onmessage = (event) => {
try {
const message: WSMessage = JSON.parse(event.data);
this.handleMessage(message);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.emit('error', error);
};
this.ws.onclose = (event) => {
this.state = ConnectionState.DISCONNECTED;
this.stopHeartbeat();
this.emit('disconnected', event.code, event.reason);
if (this.config.autoReconnect && !event.wasClean) {
this.scheduleReconnect();
}
};
}
/**
* Handle incoming message
*/
private handleMessage(message: WSMessage): void {
// Handle control messages
switch (message.type) {
case WSMessageType.PONG:
this.lastPongTime = Date.now();
return;
case WSMessageType.HEARTBEAT:
this.send({
id: message.id,
type: WSMessageType.PONG,
timestamp: new Date().toISOString(),
data: null
});
return;
}
// Emit message for subscribers
this.emit('message', message);
this.emit(message.type, message.data);
}
/**
* Send a message
*/
public send(message: WSMessage): void {
if (this.state === ConnectionState.CONNECTED && this.ws?.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify(message));
} catch (error) {
console.error('Failed to send WebSocket message:', error);
this.queueMessage(message);
}
} else {
this.queueMessage(message);
}
}
/**
* Queue a message for later sending
*/
private queueMessage(message: WSMessage): void {
if (this.messageQueue.length >= ToolWebSocketManager.MAX_QUEUE_SIZE) {
this.messageQueue.shift(); // Remove oldest message
}
this.messageQueue.push(message);
}
/**
* Flush message queue
*/
private flushMessageQueue(): void {
while (this.messageQueue.length > 0 && this.state === ConnectionState.CONNECTED) {
const message = this.messageQueue.shift();
if (message) {
this.send(message);
}
}
}
/**
* Subscribe to tool execution updates
*/
public subscribe(executionId: string): void {
this.subscriptions.add(executionId);
if (this.state === ConnectionState.CONNECTED) {
this.send({
id: this.generateMessageId(),
type: WSMessageType.SUBSCRIBE,
timestamp: new Date().toISOString(),
data: { executionId }
});
}
}
/**
* Unsubscribe from tool execution updates
*/
public unsubscribe(executionId: string): void {
this.subscriptions.delete(executionId);
if (this.state === ConnectionState.CONNECTED) {
this.send({
id: this.generateMessageId(),
type: WSMessageType.UNSUBSCRIBE,
timestamp: new Date().toISOString(),
data: { executionId }
});
}
}
/**
* Re-subscribe to all previous subscriptions
*/
private resubscribe(): void {
this.subscriptions.forEach(executionId => {
this.send({
id: this.generateMessageId(),
type: WSMessageType.SUBSCRIBE,
timestamp: new Date().toISOString(),
data: { executionId }
});
});
}
/**
* Start heartbeat mechanism
*/
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = window.setInterval(() => {
if (this.state === ConnectionState.CONNECTED) {
// Check if last pong was received
if (this.lastPingTime && this.lastPongTime) {
const timeSinceLastPong = Date.now() - this.lastPongTime;
if (timeSinceLastPong > this.config.heartbeatInterval * 2) {
// Connection seems dead, reconnect
this.reconnect();
return;
}
}
// Send ping
this.lastPingTime = Date.now();
this.send({
id: this.generateMessageId(),
type: WSMessageType.PING,
timestamp: new Date().toISOString(),
data: null
});
}
}, this.config.heartbeatInterval);
}
/**
* Stop heartbeat mechanism
*/
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
}
/**
* Schedule reconnection attempt
*/
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
this.state = ConnectionState.FAILED;
this.emit('failed', 'Max reconnection attempts reached');
return;
}
this.state = ConnectionState.RECONNECTING;
this.reconnectAttempts++;
const delay = Math.min(
this.config.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
30000 // Max 30 seconds
);
this.emit('reconnecting', this.reconnectAttempts, delay);
this.reconnectTimer = window.setTimeout(() => {
this.connect();
}, delay);
}
/**
* Reconnect to server
*/
public reconnect(): void {
this.disconnect(false);
this.connect();
}
/**
* Handle connection error
*/
private handleConnectionError(error: any): void {
console.error('WebSocket connection error:', error);
this.state = ConnectionState.DISCONNECTED;
this.emit('error', error);
if (this.config.autoReconnect) {
this.scheduleReconnect();
}
}
/**
* Disconnect from server
*/
public disconnect(clearSubscriptions: boolean = true): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
this.stopHeartbeat();
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
this.ws = undefined;
}
if (clearSubscriptions) {
this.subscriptions.clear();
}
this.messageQueue = [];
this.state = ConnectionState.DISCONNECTED;
}
/**
* Get connection state
*/
public getState(): ConnectionState {
return this.state;
}
/**
* Check if connected
*/
public isConnected(): boolean {
return this.state === ConnectionState.CONNECTED;
}
/**
* Generate unique message ID
*/
private generateMessageId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Destroy the WebSocket manager
*/
public destroy(): void {
this.disconnect(true);
this.removeAllListeners();
}
}
/**
* Create WebSocket manager instance
*/
export function createToolWebSocket(config: WSConfig): ToolWebSocketManager {
return new ToolWebSocketManager(config);
}

View File

@ -0,0 +1,312 @@
/**
* Virtual Scrolling Component
*
* Provides efficient rendering of large lists by only rendering visible items.
* Optimized for the tool execution history display.
*/
export interface VirtualScrollOptions {
container: HTMLElement;
itemHeight: number;
totalItems: number;
renderBuffer?: number;
overscan?: number;
onRenderItem: (index: number) => HTMLElement;
onScrollEnd?: () => void;
}
export interface VirtualScrollItem {
index: number;
element: HTMLElement;
top: number;
}
/**
* Virtual Scroll Manager
*/
export class VirtualScrollManager {
private container: HTMLElement;
private viewport: HTMLElement;
private content: HTMLElement;
private itemHeight: number;
private totalItems: number;
private renderBuffer: number;
private overscan: number;
private onRenderItem: (index: number) => HTMLElement;
private onScrollEnd?: () => void;
private visibleItems: Map<number, VirtualScrollItem> = new Map();
private scrollRAF?: number;
private lastScrollTop: number = 0;
private scrollEndTimeout?: number;
// Performance optimization constants
private static readonly DEFAULT_RENDER_BUFFER = 3;
private static readonly DEFAULT_OVERSCAN = 2;
private static readonly SCROLL_END_DELAY = 150;
private static readonly RECYCLE_POOL_SIZE = 50;
// Element recycling pool
private recyclePool: HTMLElement[] = [];
constructor(options: VirtualScrollOptions) {
this.container = options.container;
this.itemHeight = options.itemHeight;
this.totalItems = options.totalItems;
this.renderBuffer = options.renderBuffer ?? VirtualScrollManager.DEFAULT_RENDER_BUFFER;
this.overscan = options.overscan ?? VirtualScrollManager.DEFAULT_OVERSCAN;
this.onRenderItem = options.onRenderItem;
this.onScrollEnd = options.onScrollEnd;
this.setupStructure();
this.attachListeners();
this.render();
}
/**
* Setup DOM structure for virtual scrolling
*/
private setupStructure(): void {
// Create viewport (scrollable container)
this.viewport = document.createElement('div');
this.viewport.className = 'virtual-scroll-viewport';
this.viewport.style.cssText = `
height: 100%;
overflow-y: auto;
position: relative;
`;
// Create content (holds actual items)
this.content = document.createElement('div');
this.content.className = 'virtual-scroll-content';
this.content.style.cssText = `
position: relative;
height: ${this.totalItems * this.itemHeight}px;
`;
this.viewport.appendChild(this.content);
this.container.appendChild(this.viewport);
}
/**
* Attach scroll listeners
*/
private attachListeners(): void {
this.viewport.addEventListener('scroll', this.handleScroll.bind(this), { passive: true });
// Use ResizeObserver for dynamic container size changes
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
this.render();
});
resizeObserver.observe(this.viewport);
}
}
/**
* Handle scroll events with requestAnimationFrame
*/
private handleScroll(): void {
if (this.scrollRAF) {
cancelAnimationFrame(this.scrollRAF);
}
this.scrollRAF = requestAnimationFrame(() => {
this.render();
this.detectScrollEnd();
});
}
/**
* Detect when scrolling has ended
*/
private detectScrollEnd(): void {
const scrollTop = this.viewport.scrollTop;
if (this.scrollEndTimeout) {
clearTimeout(this.scrollEndTimeout);
}
this.scrollEndTimeout = window.setTimeout(() => {
if (scrollTop === this.lastScrollTop) {
this.onScrollEnd?.();
}
this.lastScrollTop = scrollTop;
}, VirtualScrollManager.SCROLL_END_DELAY);
}
/**
* Render visible items
*/
private render(): void {
const scrollTop = this.viewport.scrollTop;
const viewportHeight = this.viewport.clientHeight;
// Calculate visible range with overscan
const startIndex = Math.max(0,
Math.floor(scrollTop / this.itemHeight) - this.overscan
);
const endIndex = Math.min(this.totalItems - 1,
Math.ceil((scrollTop + viewportHeight) / this.itemHeight) + this.overscan
);
// Remove items that are no longer visible
this.removeInvisibleItems(startIndex, endIndex);
// Add new visible items
for (let i = startIndex; i <= endIndex; i++) {
if (!this.visibleItems.has(i)) {
this.addItem(i);
}
}
}
/**
* Remove items outside visible range
*/
private removeInvisibleItems(startIndex: number, endIndex: number): void {
const itemsToRemove: number[] = [];
this.visibleItems.forEach((item, index) => {
if (index < startIndex - this.renderBuffer || index > endIndex + this.renderBuffer) {
itemsToRemove.push(index);
}
});
itemsToRemove.forEach(index => {
const item = this.visibleItems.get(index);
if (item) {
this.recycleElement(item.element);
this.visibleItems.delete(index);
}
});
}
/**
* Add a single item to the visible list
*/
private addItem(index: number): void {
const element = this.getOrCreateElement(index);
const top = index * this.itemHeight;
element.style.cssText = `
position: absolute;
top: ${top}px;
left: 0;
right: 0;
height: ${this.itemHeight}px;
`;
this.content.appendChild(element);
this.visibleItems.set(index, {
index,
element,
top
});
}
/**
* Get or create an element (with recycling)
*/
private getOrCreateElement(index: number): HTMLElement {
let element = this.recyclePool.pop();
if (element) {
// Clear previous content
element.innerHTML = '';
element.className = '';
} else {
element = document.createElement('div');
}
// Render new content
const content = this.onRenderItem(index);
if (content !== element) {
element.appendChild(content);
}
return element;
}
/**
* Recycle an element for reuse
*/
private recycleElement(element: HTMLElement): void {
element.remove();
if (this.recyclePool.length < VirtualScrollManager.RECYCLE_POOL_SIZE) {
this.recyclePool.push(element);
}
}
/**
* Update total items and re-render
*/
public updateTotalItems(totalItems: number): void {
this.totalItems = totalItems;
this.content.style.height = `${totalItems * this.itemHeight}px`;
this.render();
}
/**
* Scroll to a specific index
*/
public scrollToIndex(index: number, behavior: ScrollBehavior = 'smooth'): void {
const top = index * this.itemHeight;
this.viewport.scrollTo({
top,
behavior
});
}
/**
* Get current scroll position
*/
public getScrollPosition(): { index: number; offset: number } {
const scrollTop = this.viewport.scrollTop;
const index = Math.floor(scrollTop / this.itemHeight);
const offset = scrollTop % this.itemHeight;
return { index, offset };
}
/**
* Refresh visible items
*/
public refresh(): void {
this.visibleItems.forEach(item => {
item.element.remove();
});
this.visibleItems.clear();
this.render();
}
/**
* Destroy the virtual scroll manager
*/
public destroy(): void {
if (this.scrollRAF) {
cancelAnimationFrame(this.scrollRAF);
}
if (this.scrollEndTimeout) {
clearTimeout(this.scrollEndTimeout);
}
this.visibleItems.forEach(item => {
item.element.remove();
});
this.visibleItems.clear();
this.recyclePool = [];
this.viewport.remove();
}
}
/**
* Create a virtual scroll instance
*/
export function createVirtualScroll(options: VirtualScrollOptions): VirtualScrollManager {
return new VirtualScrollManager(options);
}

View File

@ -0,0 +1,298 @@
/**
* API routes for enhanced LLM tool functionality
*/
import express from 'express';
import log from '../../services/log.js';
import { toolPreviewManager } from '../../services/llm/tools/tool_preview.js';
import { toolFeedbackManager } from '../../services/llm/tools/tool_feedback.js';
import { toolErrorRecoveryManager, ToolErrorType } from '../../services/llm/tools/tool_error_recovery.js';
import toolRegistry from '../../services/llm/tools/tool_registry.js';
const router = express.Router();
/**
* Get tool preview for pending executions
*/
router.post('/preview', async (req, res) => {
try {
const { toolCalls } = req.body;
if (!toolCalls || !Array.isArray(toolCalls)) {
return res.status(400).json({
error: 'Invalid request: toolCalls array required'
});
}
// Get tool handlers
const handlers = new Map();
for (const toolCall of toolCalls) {
const tool = toolRegistry.getTool(toolCall.function.name);
if (tool) {
handlers.set(toolCall.function.name, tool);
}
}
// Create execution plan
const plan = toolPreviewManager.createExecutionPlan(toolCalls, handlers);
res.json(plan);
} catch (error: any) {
log.error(`Error creating tool preview: ${error.message}`);
res.status(500).json({
error: 'Failed to create tool preview',
message: error.message
});
}
});
/**
* Submit tool approval/rejection
*/
router.post('/preview/:planId/approval', async (req, res) => {
try {
const { planId } = req.params;
const approval = req.body;
if (!approval || typeof approval.approved === 'undefined') {
return res.status(400).json({
error: 'Invalid approval data'
});
}
approval.planId = planId;
toolPreviewManager.recordApproval(approval);
res.json({
success: true,
message: approval.approved ? 'Execution approved' : 'Execution rejected'
});
} catch (error: any) {
log.error(`Error recording approval: ${error.message}`);
res.status(500).json({
error: 'Failed to record approval',
message: error.message
});
}
});
/**
* Get active tool executions
*/
router.get('/executions/active', async (req, res) => {
try {
const executions = toolFeedbackManager.getActiveExecutions();
res.json(executions);
} catch (error: any) {
log.error(`Error getting active executions: ${error.message}`);
res.status(500).json({
error: 'Failed to get active executions',
message: error.message
});
}
});
/**
* Get tool execution history
*/
router.get('/executions/history', async (req, res) => {
try {
const { toolName, status, limit } = req.query;
const filter: any = {};
if (toolName) filter.toolName = String(toolName);
if (status) filter.status = String(status);
if (limit) filter.limit = parseInt(String(limit), 10);
const history = toolFeedbackManager.getHistory(filter);
res.json(history);
} catch (error: any) {
log.error(`Error getting execution history: ${error.message}`);
res.status(500).json({
error: 'Failed to get execution history',
message: error.message
});
}
});
/**
* Get tool execution statistics
*/
router.get('/executions/stats', async (req, res) => {
try {
const stats = toolFeedbackManager.getStatistics();
res.json(stats);
} catch (error: any) {
log.error(`Error getting execution statistics: ${error.message}`);
res.status(500).json({
error: 'Failed to get execution statistics',
message: error.message
});
}
});
/**
* Cancel a running tool execution
*/
router.post('/executions/:executionId/cancel', async (req, res) => {
try {
const { executionId } = req.params;
const { reason } = req.body;
const success = toolFeedbackManager.cancelExecution(
executionId,
'api',
reason
);
if (success) {
res.json({
success: true,
message: 'Execution cancelled'
});
} else {
res.status(404).json({
error: 'Execution not found or not cancellable'
});
}
} catch (error: any) {
log.error(`Error cancelling execution: ${error.message}`);
res.status(500).json({
error: 'Failed to cancel execution',
message: error.message
});
}
});
/**
* Get circuit breaker status for tools
*/
router.get('/circuit-breakers', async (req, res) => {
try {
const tools = toolRegistry.getAllTools();
const statuses: any[] = [];
for (const tool of tools) {
const toolName = tool.definition.function.name;
const state = toolErrorRecoveryManager.getCircuitBreakerState(toolName);
statuses.push({
toolName,
displayName: tool.definition.function.name,
state: state || 'closed',
errorHistory: toolErrorRecoveryManager.getErrorHistory(toolName).length
});
}
res.json(statuses);
} catch (error: any) {
log.error(`Error getting circuit breaker status: ${error.message}`);
res.status(500).json({
error: 'Failed to get circuit breaker status',
message: error.message
});
}
});
/**
* Reset circuit breaker for a tool
*/
router.post('/circuit-breakers/:toolName/reset', async (req, res) => {
try {
const { toolName } = req.params;
toolErrorRecoveryManager.resetCircuitBreaker(toolName);
res.json({
success: true,
message: `Circuit breaker reset for ${toolName}`
});
} catch (error: any) {
log.error(`Error resetting circuit breaker: ${error.message}`);
res.status(500).json({
error: 'Failed to reset circuit breaker',
message: error.message
});
}
});
/**
* Get error recovery suggestions
*/
router.post('/errors/suggest-recovery', async (req, res) => {
try {
const { toolName, error, parameters } = req.body;
if (!toolName || !error) {
return res.status(400).json({
error: 'toolName and error are required'
});
}
// Categorize the error
const categorizedError = toolErrorRecoveryManager.categorizeError(error);
// Get recovery suggestions
const suggestions = toolErrorRecoveryManager.suggestRecoveryActions(
toolName,
categorizedError,
parameters || {}
);
res.json({
error: categorizedError,
suggestions
});
} catch (error: any) {
log.error(`Error getting recovery suggestions: ${error.message}`);
res.status(500).json({
error: 'Failed to get recovery suggestions',
message: error.message
});
}
});
/**
* Test tool execution with mock data
*/
router.post('/test/:toolName', async (req, res) => {
try {
const { toolName } = req.params;
const { parameters } = req.body;
const tool = toolRegistry.getTool(toolName);
if (!tool) {
return res.status(404).json({
error: `Tool not found: ${toolName}`
});
}
// Create a mock tool call
const toolCall = {
id: `test-${Date.now()}`,
function: {
name: toolName,
arguments: parameters || {}
}
};
// Execute with recovery
const result = await toolErrorRecoveryManager.executeWithRecovery(
toolCall,
tool,
(attempt, delay) => {
log.info(`Test execution retry: attempt ${attempt}, delay ${delay}ms`);
}
);
res.json(result);
} catch (error: any) {
log.error(`Error testing tool: ${error.message}`);
res.status(500).json({
error: 'Failed to test tool',
message: error.message
});
}
});
export default router;

View File

@ -0,0 +1,333 @@
/**
* Enhanced Handler for LLM tool executions with preview, feedback, and error recovery
*/
import log from "../../../log.js";
import type { Message } from "../../ai_interface.js";
import type { ToolCall } from "../../tools/tool_interfaces.js";
import { toolPreviewManager, type ToolExecutionPlan, type ToolApproval } from "../../tools/tool_preview.js";
import { toolFeedbackManager, type ToolExecutionProgress } from "../../tools/tool_feedback.js";
import { toolErrorRecoveryManager, type ToolError } from "../../tools/tool_error_recovery.js";
/**
* Tool execution options
*/
export interface ToolExecutionOptions {
requireConfirmation?: boolean;
enablePreview?: boolean;
enableFeedback?: boolean;
enableErrorRecovery?: boolean;
timeout?: number;
onPreview?: (plan: ToolExecutionPlan) => Promise<ToolApproval>;
onProgress?: (executionId: string, progress: ToolExecutionProgress) => void;
onStep?: (executionId: string, step: any) => void;
onError?: (executionId: string, error: ToolError) => void;
onComplete?: (executionId: string, result: any) => void;
}
/**
* Enhanced tool handler with preview, feedback, and error recovery
*/
export class EnhancedToolHandler {
/**
* Execute tool calls with enhanced features
*/
static async executeToolCalls(
response: any,
chatNoteId?: string,
options: ToolExecutionOptions = {}
): Promise<Message[]> {
log.info(`========== ENHANCED TOOL EXECUTION FLOW ==========`);
if (!response.tool_calls || response.tool_calls.length === 0) {
log.info(`No tool calls to execute, returning early`);
return [];
}
log.info(`Executing ${response.tool_calls.length} tool calls with enhanced features`);
try {
// Import tool registry
const toolRegistry = (await import('../../tools/tool_registry.js')).default;
// Check if tools are available
const availableTools = toolRegistry.getAllTools();
log.info(`Available tools in registry: ${availableTools.length}`);
if (availableTools.length === 0) {
log.error('No tools available in registry for execution');
throw new Error('Tool execution failed: No tools available');
}
// Create handlers map
const handlers = new Map<string, any>();
for (const toolCall of response.tool_calls) {
const tool = toolRegistry.getTool(toolCall.function.name);
if (tool) {
handlers.set(toolCall.function.name, tool);
}
}
// Phase 1: Tool Preview
let executionPlan: ToolExecutionPlan | undefined;
let approval: ToolApproval | undefined;
if (options.enablePreview !== false) {
executionPlan = toolPreviewManager.createExecutionPlan(response.tool_calls, handlers);
log.info(`Created execution plan ${executionPlan.id} with ${executionPlan.tools.length} tools`);
log.info(`Estimated duration: ${executionPlan.totalEstimatedDuration}ms`);
log.info(`Requires confirmation: ${executionPlan.requiresConfirmation}`);
// Check if confirmation is required
if (options.requireConfirmation && executionPlan.requiresConfirmation) {
if (options.onPreview) {
// Get approval from client
approval = await options.onPreview(executionPlan);
toolPreviewManager.recordApproval(approval);
if (!approval.approved) {
log.info(`Execution plan ${executionPlan.id} was rejected`);
return [{
role: 'system',
content: 'Tool execution was cancelled by user'
}];
}
} else {
// Auto-approve if no preview handler provided
approval = {
planId: executionPlan.id,
approved: true,
approvedBy: 'system'
};
toolPreviewManager.recordApproval(approval);
}
}
}
// Phase 2: Execute tools with feedback and error recovery
const toolResults = await Promise.all(response.tool_calls.map(async (toolCall: ToolCall) => {
// Check if this tool was rejected
if (approval?.rejectedTools?.includes(toolCall.function.name)) {
log.info(`Skipping rejected tool: ${toolCall.function.name}`);
return {
role: 'tool',
content: 'Tool execution was rejected by user',
name: toolCall.function.name,
tool_call_id: toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
};
}
// Start feedback tracking
let executionId: string | undefined;
if (options.enableFeedback !== false) {
executionId = toolFeedbackManager.startExecution(toolCall, options.timeout);
}
try {
log.info(`Executing tool: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
// Get the tool from registry
const tool = toolRegistry.getTool(toolCall.function.name);
if (!tool) {
const error = `Tool not found: ${toolCall.function.name}`;
if (executionId) {
toolFeedbackManager.failExecution(executionId, error);
}
throw new Error(error);
}
// Parse arguments (with modifications if provided)
let args = typeof toolCall.function.arguments === 'string'
? JSON.parse(toolCall.function.arguments)
: toolCall.function.arguments;
// Apply parameter modifications from approval if any
if (approval?.modifiedParameters?.[toolCall.function.name]) {
args = { ...args, ...approval.modifiedParameters[toolCall.function.name] };
log.info(`Applied modified parameters for ${toolCall.function.name}`);
}
// Add execution step
if (executionId) {
toolFeedbackManager.addStep(executionId, {
timestamp: new Date(),
message: `Starting ${toolCall.function.name} execution`,
type: 'info',
data: { arguments: args }
});
if (options.onStep) {
options.onStep(executionId, {
type: 'start',
tool: toolCall.function.name,
arguments: args
});
}
}
// Execute with error recovery if enabled
let result: any;
let executionTime: number;
if (options.enableErrorRecovery !== false) {
const executionResult = await toolErrorRecoveryManager.executeWithRecovery(
{ ...toolCall, function: { ...toolCall.function, arguments: args } },
tool,
(attempt, delay) => {
if (executionId) {
toolFeedbackManager.addStep(executionId, {
timestamp: new Date(),
message: `Retry attempt ${attempt} after ${delay}ms`,
type: 'warning'
});
if (options.onProgress) {
options.onProgress(executionId, {
current: attempt,
total: 3,
percentage: (attempt / 3) * 100,
message: `Retrying...`
});
}
}
}
);
if (!executionResult.success) {
const error = executionResult.error;
if (executionId) {
toolFeedbackManager.failExecution(executionId, error?.message || 'Unknown error');
}
if (options.onError && executionId && error) {
options.onError(executionId, error);
}
// Suggest recovery actions
if (error) {
const recoveryActions = toolErrorRecoveryManager.suggestRecoveryActions(
toolCall.function.name,
error,
args
);
log.info(`Recovery suggestions: ${recoveryActions.map(a => a.description).join(', ')}`);
}
throw new Error(error?.userMessage || error?.message || 'Tool execution failed');
}
result = executionResult.data;
executionTime = executionResult.totalDuration;
if (executionResult.recovered) {
log.info(`Tool ${toolCall.function.name} recovered after ${executionResult.attempts} attempts`);
}
} else {
// Direct execution without error recovery
const startTime = Date.now();
result = await tool.execute(args);
executionTime = Date.now() - startTime;
}
// Complete feedback tracking
if (executionId) {
toolFeedbackManager.completeExecution(executionId, result);
if (options.onComplete) {
options.onComplete(executionId, result);
}
}
log.info(`Tool execution completed in ${executionTime}ms`);
// Log the result preview
const resultPreview = typeof result === 'string'
? result.substring(0, 100) + (result.length > 100 ? '...' : '')
: JSON.stringify(result).substring(0, 100) + '...';
log.info(`Tool result: ${resultPreview}`);
// Format result as a proper message
return {
role: 'tool',
content: typeof result === 'string' ? result : JSON.stringify(result),
name: toolCall.function.name,
tool_call_id: toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
};
} catch (error: any) {
log.error(`Error executing tool ${toolCall.function.name}: ${error.message}`);
// Fail execution tracking
if (executionId) {
toolFeedbackManager.failExecution(executionId, error.message);
}
// Categorize error for better reporting
const categorizedError = toolErrorRecoveryManager.categorizeError(error);
if (options.onError && executionId) {
options.onError(executionId, categorizedError);
}
// Return error as tool result
return {
role: 'tool',
content: categorizedError.userMessage || `Error: ${error.message}`,
name: toolCall.function.name,
tool_call_id: toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
};
}
}));
log.info(`Completed execution of ${toolResults.length} tools`);
// Get execution statistics if feedback is enabled
if (options.enableFeedback !== false) {
const stats = toolFeedbackManager.getStatistics();
log.info(`Execution statistics: ${stats.successfulExecutions} successful, ${stats.failedExecutions} failed`);
}
return toolResults;
} catch (error: any) {
log.error(`Error in enhanced tool execution handler: ${error.message}`);
throw error;
}
}
/**
* Get tool execution history
*/
static getExecutionHistory(filter?: any) {
return toolFeedbackManager.getHistory(filter);
}
/**
* Get tool execution statistics
*/
static getExecutionStatistics() {
return toolFeedbackManager.getStatistics();
}
/**
* Cancel a running tool execution
*/
static cancelExecution(executionId: string, reason?: string): boolean {
return toolFeedbackManager.cancelExecution(executionId, 'user', reason);
}
/**
* Get active tool executions
*/
static getActiveExecutions() {
return toolFeedbackManager.getActiveExecutions();
}
/**
* Clean up old execution data
*/
static cleanup() {
toolPreviewManager.cleanup();
toolFeedbackManager.clear();
toolErrorRecoveryManager.clearHistory();
}
}

View File

@ -3,6 +3,9 @@
*/
import log from "../../../log.js";
import type { Message } from "../../ai_interface.js";
import { toolPreviewManager } from "../../tools/tool_preview.js";
import { toolFeedbackManager } from "../../tools/tool_feedback.js";
import { toolErrorRecoveryManager } from "../../tools/tool_error_recovery.js";
/**
* Handles the execution of LLM tools
@ -12,8 +15,17 @@ export class ToolHandler {
* Execute tool calls from the LLM response
* @param response The LLM response containing tool calls
* @param chatNoteId Optional chat note ID for tracking
* @param options Execution options
*/
static async executeToolCalls(response: any, chatNoteId?: string): Promise<Message[]> {
static async executeToolCalls(
response: any,
chatNoteId?: string,
options?: {
requireConfirmation?: boolean;
onProgress?: (executionId: string, progress: any) => void;
onError?: (executionId: string, error: any) => void;
}
): Promise<Message[]> {
log.info(`========== TOOL EXECUTION FLOW ==========`);
if (!response.tool_calls || response.tool_calls.length === 0) {
log.info(`No tool calls to execute, returning early`);

View File

@ -1,20 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as configHelpers from './configuration_helpers.js';
import configurationManager from './configuration_manager.js';
import optionService from '../../options.js';
import type { ProviderType, ModelIdentifier, ModelConfig } from '../interfaces/configuration_interfaces.js';
// Mock dependencies - configuration manager is no longer used
vi.mock('./configuration_manager.js', () => ({
default: {
parseModelIdentifier: vi.fn(),
createModelConfig: vi.fn(),
getAIConfig: vi.fn(),
validateConfig: vi.fn(),
clearCache: vi.fn()
}
}));
// Mock dependencies
vi.mock('../../options.js', () => ({
default: {
getOption: vi.fn(),

View File

@ -1,110 +0,0 @@
import type { Message } from "../ai_interface.js";
/**
* Interface for provider-specific message formatters
* This allows each provider to have custom formatting logic while maintaining a consistent interface
*/
export interface MessageFormatter {
/**
* Format messages for a specific LLM provider
*
* @param messages Array of messages to format
* @param systemPrompt Optional system prompt to include
* @param context Optional context to incorporate into messages
* @returns Formatted messages ready to send to the provider
*/
formatMessages(messages: Message[], systemPrompt?: string, context?: string): Message[];
/**
* Clean context content to prepare it for this specific provider
*
* @param content The raw context content
* @returns Cleaned and formatted context content
*/
cleanContextContent(content: string): string;
/**
* Get the maximum recommended context length for this provider
*
* @returns Maximum context length in characters
*/
getMaxContextLength(): number;
}
/**
* Default message formatter implementation
*/
class DefaultMessageFormatter implements MessageFormatter {
formatMessages(messages: Message[], systemPrompt?: string, context?: string): Message[] {
const formattedMessages: Message[] = [];
// Add system prompt if provided
if (systemPrompt || context) {
const systemContent = [systemPrompt, context].filter(Boolean).join('\n\n');
if (systemContent) {
formattedMessages.push({
role: 'system',
content: systemContent
});
}
}
// Add the rest of the messages
formattedMessages.push(...messages);
return formattedMessages;
}
cleanContextContent(content: string): string {
// Basic cleanup: trim and remove excessive whitespace
return content.trim().replace(/\n{3,}/g, '\n\n');
}
getMaxContextLength(): number {
// Default to a reasonable context length
return 10000;
}
}
/**
* Factory to get the appropriate message formatter for a provider
*/
export class MessageFormatterFactory {
// Cache formatters for reuse
private static formatters: Record<string, MessageFormatter> = {};
/**
* Get the appropriate message formatter for a provider
*
* @param providerName Name of the LLM provider (e.g., 'openai', 'anthropic', 'ollama')
* @returns MessageFormatter instance for the specified provider
*/
static getFormatter(providerName: string): MessageFormatter {
// Normalize provider name and handle variations
let providerKey: string;
// Normalize provider name from various forms (constructor.name, etc.)
if (providerName.toLowerCase().includes('openai')) {
providerKey = 'openai';
} else if (providerName.toLowerCase().includes('anthropic') ||
providerName.toLowerCase().includes('claude')) {
providerKey = 'anthropic';
} else if (providerName.toLowerCase().includes('ollama')) {
providerKey = 'ollama';
} else {
// Default to lowercase of whatever name we got
providerKey = providerName.toLowerCase();
}
// Return cached formatter if available
if (this.formatters[providerKey]) {
return this.formatters[providerKey];
}
// For now, all providers use the default formatter
// In the future, we can add provider-specific formatters here
this.formatters[providerKey] = new DefaultMessageFormatter();
return this.formatters[providerKey];
}
}

View File

@ -1,226 +0,0 @@
import type { Message } from '../../ai_interface.js';
import { MESSAGE_FORMATTER_TEMPLATES, PROVIDER_IDENTIFIERS } from '../../constants/formatter_constants.js';
/**
* Interface for message formatters that handle provider-specific message formatting
*/
export interface MessageFormatter {
/**
* Format messages with system prompt and context in provider-specific way
* @param messages Original messages
* @param systemPrompt Optional system prompt to override
* @param context Optional context to include
* @param preserveSystemPrompt Optional flag to preserve existing system prompt
* @returns Formatted messages optimized for the specific provider
*/
formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean): Message[];
}
/**
* Base message formatter with common functionality
*/
export abstract class BaseMessageFormatter implements MessageFormatter {
/**
* Format messages with system prompt and context
* Each provider should override this method with their specific formatting strategy
*/
abstract formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean): Message[];
/**
* Helper method to extract existing system message from messages
*/
protected getSystemMessage(messages: Message[]): Message | undefined {
return messages.find(msg => msg.role === 'system');
}
/**
* Helper method to create a copy of messages without system message
*/
protected getMessagesWithoutSystem(messages: Message[]): Message[] {
return messages.filter(msg => msg.role !== 'system');
}
}
/**
* OpenAI-specific message formatter
* Optimizes message format for OpenAI models (GPT-3.5, GPT-4, etc.)
*/
export class OpenAIMessageFormatter extends BaseMessageFormatter {
formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean): Message[] {
const formattedMessages: Message[] = [];
// OpenAI performs best with system message first, then context as a separate system message
// or appended to the original system message
// Handle system message
const existingSystem = this.getSystemMessage(messages);
if (preserveSystemPrompt && existingSystem) {
// Use the existing system message
formattedMessages.push(existingSystem);
} else if (systemPrompt || existingSystem) {
const systemContent = systemPrompt || existingSystem?.content || '';
formattedMessages.push({
role: 'system',
content: systemContent
});
}
// Add context as a system message with clear instruction
if (context) {
formattedMessages.push({
role: 'system',
content: MESSAGE_FORMATTER_TEMPLATES.OPENAI.CONTEXT_INSTRUCTION + context
});
}
// Add remaining messages (excluding system)
formattedMessages.push(...this.getMessagesWithoutSystem(messages));
return formattedMessages;
}
}
/**
* Anthropic-specific message formatter
* Optimizes message format for Claude models
*/
export class AnthropicMessageFormatter extends BaseMessageFormatter {
formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean): Message[] {
const formattedMessages: Message[] = [];
// Anthropic performs best with a specific XML-like format for context and system instructions
// Create system message with combined prompt and context if any
let systemContent = '';
const existingSystem = this.getSystemMessage(messages);
if (preserveSystemPrompt && existingSystem) {
systemContent = existingSystem.content;
} else if (systemPrompt || existingSystem) {
systemContent = systemPrompt || existingSystem?.content || '';
}
// For Claude, wrap context in XML tags for clear separation
if (context) {
systemContent += MESSAGE_FORMATTER_TEMPLATES.ANTHROPIC.CONTEXT_START + context + MESSAGE_FORMATTER_TEMPLATES.ANTHROPIC.CONTEXT_END;
}
// Add system message if we have content
if (systemContent) {
formattedMessages.push({
role: 'system',
content: systemContent
});
}
// Add remaining messages (excluding system)
formattedMessages.push(...this.getMessagesWithoutSystem(messages));
return formattedMessages;
}
}
/**
* Ollama-specific message formatter
* Optimizes message format for open-source models
*/
export class OllamaMessageFormatter extends BaseMessageFormatter {
formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean): Message[] {
const formattedMessages: Message[] = [];
// Ollama format is closer to raw prompting and typically works better with
// context embedded in system prompt rather than as separate messages
// Build comprehensive system prompt
let systemContent = '';
const existingSystem = this.getSystemMessage(messages);
if (systemPrompt || existingSystem) {
systemContent = systemPrompt || existingSystem?.content || '';
}
// Add context to system prompt
if (context) {
systemContent += MESSAGE_FORMATTER_TEMPLATES.OLLAMA.REFERENCE_INFORMATION + context;
}
// Add system message if we have content
if (systemContent) {
formattedMessages.push({
role: 'system',
content: systemContent
});
}
// Add remaining messages (excluding system)
formattedMessages.push(...this.getMessagesWithoutSystem(messages));
return formattedMessages;
}
}
/**
* Default message formatter when provider is unknown
*/
export class DefaultMessageFormatter extends BaseMessageFormatter {
formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean): Message[] {
const formattedMessages: Message[] = [];
// Handle system message
const existingSystem = this.getSystemMessage(messages);
if (preserveSystemPrompt && existingSystem) {
formattedMessages.push(existingSystem);
} else if (systemPrompt || existingSystem) {
const systemContent = systemPrompt || existingSystem?.content || '';
formattedMessages.push({
role: 'system',
content: systemContent
});
}
// Add context as a user message
if (context) {
formattedMessages.push({
role: 'user',
content: MESSAGE_FORMATTER_TEMPLATES.DEFAULT.CONTEXT_INSTRUCTION + context
});
}
// Add user/assistant messages
formattedMessages.push(...this.getMessagesWithoutSystem(messages));
return formattedMessages;
}
}
/**
* Factory for creating the appropriate message formatter based on provider
*/
export class MessageFormatterFactory {
private static formatters: Record<string, MessageFormatter> = {
[PROVIDER_IDENTIFIERS.OPENAI]: new OpenAIMessageFormatter(),
[PROVIDER_IDENTIFIERS.ANTHROPIC]: new AnthropicMessageFormatter(),
[PROVIDER_IDENTIFIERS.OLLAMA]: new OllamaMessageFormatter(),
[PROVIDER_IDENTIFIERS.DEFAULT]: new DefaultMessageFormatter()
};
/**
* Get the appropriate formatter for a provider
* @param provider Provider name
* @returns Message formatter for that provider
*/
static getFormatter(provider: string): MessageFormatter {
return this.formatters[provider] || this.formatters[PROVIDER_IDENTIFIERS.DEFAULT];
}
/**
* Register a custom formatter for a provider
* @param provider Provider name
* @param formatter Custom formatter implementation
*/
static registerFormatter(provider: string, formatter: MessageFormatter): void {
this.formatters[provider] = formatter;
}
}

View File

@ -58,13 +58,21 @@ vi.mock('./logging_service.js', () => ({
vi.mock('../ai_service_manager.js', () => ({
default: {
getService: vi.fn(() => ({
getService: vi.fn(async () => ({
chat: vi.fn(async (messages, options) => ({
text: 'Test response',
model: 'test-model',
provider: 'test-provider',
tool_calls: options.enableTools ? [] : undefined
}))
tool_calls: options?.enableTools ? [] : undefined
})),
generateChatCompletion: vi.fn(async (messages, options) => ({
text: 'Test response',
model: 'test-model',
provider: 'test-provider',
tool_calls: options?.enableTools ? [] : undefined
})),
isAvailable: () => true,
getName: () => 'test-service'
}))
}
}));
@ -131,8 +139,11 @@ describe('SimplifiedChatPipeline', () => {
};
});
aiServiceManager.default.getService = vi.fn(() => ({
chat: mockChat
aiServiceManager.default.getService = vi.fn(async () => ({
chat: mockChat,
generateChatCompletion: mockChat,
isAvailable: () => true,
getName: () => 'test-service'
}));
const input: SimplifiedPipelineInput = {
@ -181,8 +192,11 @@ describe('SimplifiedChatPipeline', () => {
};
});
aiServiceManager.default.getService = vi.fn(() => ({
chat: mockChat
aiServiceManager.default.getService = vi.fn(async () => ({
chat: mockChat,
generateChatCompletion: mockChat,
isAvailable: () => true,
getName: () => 'test-service'
}));
const input: SimplifiedPipelineInput = {
@ -212,11 +226,15 @@ describe('SimplifiedChatPipeline', () => {
await callback({ text: 'Chunk 1', done: false });
await callback({ text: 'Chunk 2', done: false });
await callback({ text: 'Chunk 3', done: true });
return 'Chunk 1Chunk 2Chunk 3';
}
}));
aiServiceManager.default.getService = vi.fn(() => ({
chat: mockChat
aiServiceManager.default.getService = vi.fn(async () => ({
chat: mockChat,
generateChatCompletion: mockChat,
isAvailable: () => true,
getName: () => 'test-service'
}));
const input: SimplifiedPipelineInput = {
@ -255,8 +273,11 @@ describe('SimplifiedChatPipeline', () => {
]
}));
aiServiceManager.default.getService = vi.fn(() => ({
chat: mockChat
aiServiceManager.default.getService = vi.fn(async () => ({
chat: mockChat,
generateChatCompletion: mockChat,
isAvailable: () => true,
getName: () => 'test-service'
}));
const input: SimplifiedPipelineInput = {
@ -277,7 +298,7 @@ describe('SimplifiedChatPipeline', () => {
it('should handle errors gracefully', async () => {
const aiServiceManager = await import('../ai_service_manager.js');
aiServiceManager.default.getService = vi.fn(() => null);
aiServiceManager.default.getService = vi.fn(async () => null as any);
const input: SimplifiedPipelineInput = {
messages: [
@ -311,8 +332,11 @@ describe('SimplifiedChatPipeline', () => {
};
});
aiServiceManager.default.getService = vi.fn(() => ({
chat: mockChat
aiServiceManager.default.getService = vi.fn(async () => ({
chat: mockChat,
generateChatCompletion: mockChat,
isAvailable: () => true,
getName: () => 'test-service'
}));
const input: SimplifiedPipelineInput = {
@ -354,8 +378,9 @@ describe('SimplifiedChatPipeline', () => {
const response = await pipeline.execute(input);
expect(response.metadata?.requestId).toBeDefined();
expect(response.metadata.requestId).toMatch(/^req_\d+_[a-z0-9]+$/);
// Request ID should be tracked internally by the pipeline
expect(response).toBeDefined();
expect(response.text).toBeDefined();
});
});

View File

@ -0,0 +1,277 @@
/**
* Tool System Constants
*
* Centralized configuration constants for the tool system to improve
* maintainability and avoid magic numbers/strings throughout the codebase.
*/
/**
* Timing constants (in milliseconds)
*/
export const TIMING = {
// Default timeouts
DEFAULT_TOOL_TIMEOUT: 60000, // 60 seconds
CLEANUP_MAX_AGE: 3600000, // 1 hour
// Retry delays
RETRY_INITIAL_DELAY: 1000,
RETRY_MAX_DELAY: 10000,
RETRY_JITTER: 500,
// Circuit breaker
CIRCUIT_BREAKER_TIMEOUT: 60000, // 1 minute
// UI updates
HISTORY_MOVE_DELAY: 5000, // 5 seconds
STEP_COLLAPSE_DELAY: 1000, // 1 second
FADE_OUT_DURATION: 300,
// Performance
DURATION_UPDATE_INTERVAL: 100, // replaced by requestAnimationFrame
} as const;
/**
* Limits and thresholds
*/
export const LIMITS = {
// History
MAX_HISTORY_SIZE: 1000,
MAX_HISTORY_UI_SIZE: 50,
MAX_ERROR_HISTORY_SIZE: 100,
// Circuit breaker
CIRCUIT_FAILURE_THRESHOLD: 5,
CIRCUIT_SUCCESS_THRESHOLD: 2,
CIRCUIT_HALF_OPEN_REQUESTS: 3,
// Retry
MAX_RETRY_ATTEMPTS: 3,
RETRY_BACKOFF_MULTIPLIER: 2,
// UI
MAX_VISIBLE_STEPS: 3,
MAX_STRING_DISPLAY_LENGTH: 100,
MAX_STEP_CONTAINER_HEIGHT: 150, // pixels
LARGE_CONTENT_THRESHOLD: 10000, // characters
// Listeners
MAX_EVENT_LISTENERS: 100,
} as const;
/**
* Tool names and operations
*/
export const TOOL_NAMES = {
// Core tools
SEARCH_NOTES: 'search_notes',
GET_NOTE_CONTENT: 'get_note_content',
CREATE_NOTE: 'create_note',
UPDATE_NOTE: 'update_note',
DELETE_NOTE: 'delete_note',
EXECUTE_CODE: 'execute_code',
WEB_SEARCH: 'web_search',
GET_NOTE_ATTRIBUTES: 'get_note_attributes',
SET_NOTE_ATTRIBUTE: 'set_note_attribute',
NAVIGATE_NOTES: 'navigate_notes',
QUERY_DECOMPOSITION: 'query_decomposition',
CONTEXTUAL_THINKING: 'contextual_thinking',
} as const;
/**
* Sensitive operations requiring confirmation
*/
export const SENSITIVE_OPERATIONS = [
TOOL_NAMES.CREATE_NOTE,
TOOL_NAMES.UPDATE_NOTE,
TOOL_NAMES.DELETE_NOTE,
TOOL_NAMES.EXECUTE_CODE,
TOOL_NAMES.SET_NOTE_ATTRIBUTE,
'modify_note_hierarchy',
] as const;
/**
* Tool display names
*/
export const TOOL_DISPLAY_NAMES: Record<string, string> = {
[TOOL_NAMES.SEARCH_NOTES]: 'Search Notes',
[TOOL_NAMES.GET_NOTE_CONTENT]: 'Read Note',
[TOOL_NAMES.CREATE_NOTE]: 'Create Note',
[TOOL_NAMES.UPDATE_NOTE]: 'Update Note',
[TOOL_NAMES.DELETE_NOTE]: 'Delete Note',
[TOOL_NAMES.EXECUTE_CODE]: 'Execute Code',
[TOOL_NAMES.WEB_SEARCH]: 'Search Web',
[TOOL_NAMES.GET_NOTE_ATTRIBUTES]: 'Get Note Properties',
[TOOL_NAMES.SET_NOTE_ATTRIBUTE]: 'Set Note Property',
[TOOL_NAMES.NAVIGATE_NOTES]: 'Navigate Notes',
[TOOL_NAMES.QUERY_DECOMPOSITION]: 'Analyze Query',
[TOOL_NAMES.CONTEXTUAL_THINKING]: 'Process Context',
} as const;
/**
* Tool descriptions
*/
export const TOOL_DESCRIPTIONS: Record<string, string> = {
[TOOL_NAMES.SEARCH_NOTES]: 'Search through your notes database',
[TOOL_NAMES.GET_NOTE_CONTENT]: 'Retrieve the content of a specific note',
[TOOL_NAMES.CREATE_NOTE]: 'Create a new note with specified content',
[TOOL_NAMES.UPDATE_NOTE]: 'Modify an existing note',
[TOOL_NAMES.DELETE_NOTE]: 'Permanently delete a note',
[TOOL_NAMES.EXECUTE_CODE]: 'Run code in a sandboxed environment',
[TOOL_NAMES.WEB_SEARCH]: 'Search the web for information',
[TOOL_NAMES.GET_NOTE_ATTRIBUTES]: 'Retrieve note metadata and properties',
[TOOL_NAMES.SET_NOTE_ATTRIBUTE]: 'Modify note metadata',
[TOOL_NAMES.NAVIGATE_NOTES]: 'Browse through the note hierarchy',
[TOOL_NAMES.QUERY_DECOMPOSITION]: 'Break down complex queries into parts',
[TOOL_NAMES.CONTEXTUAL_THINKING]: 'Analyze context for better understanding',
} as const;
/**
* Estimated durations for tools (in milliseconds)
*/
export const TOOL_ESTIMATED_DURATIONS: Record<string, number> = {
[TOOL_NAMES.SEARCH_NOTES]: 500,
[TOOL_NAMES.GET_NOTE_CONTENT]: 200,
[TOOL_NAMES.CREATE_NOTE]: 300,
[TOOL_NAMES.UPDATE_NOTE]: 300,
[TOOL_NAMES.EXECUTE_CODE]: 2000,
[TOOL_NAMES.WEB_SEARCH]: 3000,
[TOOL_NAMES.GET_NOTE_ATTRIBUTES]: 150,
[TOOL_NAMES.SET_NOTE_ATTRIBUTE]: 250,
[TOOL_NAMES.NAVIGATE_NOTES]: 400,
[TOOL_NAMES.QUERY_DECOMPOSITION]: 1000,
[TOOL_NAMES.CONTEXTUAL_THINKING]: 1500,
} as const;
/**
* Tool risk levels
*/
export const TOOL_RISK_LEVELS: Record<string, 'low' | 'medium' | 'high'> = {
[TOOL_NAMES.SEARCH_NOTES]: 'low',
[TOOL_NAMES.GET_NOTE_CONTENT]: 'low',
[TOOL_NAMES.CREATE_NOTE]: 'medium',
[TOOL_NAMES.UPDATE_NOTE]: 'high',
[TOOL_NAMES.DELETE_NOTE]: 'high',
[TOOL_NAMES.EXECUTE_CODE]: 'high',
[TOOL_NAMES.WEB_SEARCH]: 'low',
[TOOL_NAMES.GET_NOTE_ATTRIBUTES]: 'low',
[TOOL_NAMES.SET_NOTE_ATTRIBUTE]: 'medium',
[TOOL_NAMES.NAVIGATE_NOTES]: 'low',
[TOOL_NAMES.QUERY_DECOMPOSITION]: 'low',
[TOOL_NAMES.CONTEXTUAL_THINKING]: 'low',
} as const;
/**
* Tool warnings
*/
export const TOOL_WARNINGS: Record<string, string[]> = {
[TOOL_NAMES.DELETE_NOTE]: ['This action cannot be undone'],
[TOOL_NAMES.EXECUTE_CODE]: ['Code will be executed in a sandboxed environment'],
[TOOL_NAMES.WEB_SEARCH]: ['External web search may include third-party content'],
} as const;
/**
* Error type strings for categorization
*/
export const ERROR_PATTERNS = {
NETWORK: ['ECONNREFUSED', 'ENOTFOUND', 'ENETUNREACH', 'fetch failed'],
TIMEOUT: ['ETIMEDOUT', 'timeout', 'Timeout'],
RATE_LIMIT: ['429', 'rate limit', 'too many requests'],
PERMISSION: ['401', '403', 'unauthorized', 'forbidden'],
NOT_FOUND: ['404', 'not found', 'does not exist'],
VALIDATION: ['validation', 'invalid', 'required'],
INTERNAL: ['500', 'internal', 'server error'],
} as const;
/**
* UI Style classes and icons
*/
export const UI_STYLES = {
// Status icons
STATUS_ICONS: {
success: 'bx-check-circle',
error: 'bx-error-circle',
warning: 'bx-error',
cancelled: 'bx-x-circle',
timeout: 'bx-time-five',
running: 'bx-loader-alt',
pending: 'bx-time',
},
// Step icons
STEP_ICONS: {
info: 'bx-info-circle',
warning: 'bx-error',
error: 'bx-error-circle',
progress: 'bx-loader-alt',
},
// Color mappings
STATUS_COLORS: {
success: 'success',
error: 'danger',
warning: 'warning',
cancelled: 'warning',
timeout: 'danger',
info: 'muted',
progress: 'primary',
},
// Border colors
BORDER_COLORS: {
success: 'border-success',
error: 'border-danger',
warning: 'border-warning',
cancelled: 'border-warning',
timeout: 'border-danger',
},
} as const;
/**
* Alternative tool mappings for error recovery
*/
export const TOOL_ALTERNATIVES: Record<string, string[]> = {
[TOOL_NAMES.WEB_SEARCH]: [TOOL_NAMES.SEARCH_NOTES],
[TOOL_NAMES.EXECUTE_CODE]: [TOOL_NAMES.GET_NOTE_CONTENT],
[TOOL_NAMES.UPDATE_NOTE]: [TOOL_NAMES.CREATE_NOTE],
} as const;
/**
* ID generation prefixes
*/
export const ID_PREFIXES = {
PREVIEW: 'preview',
PLAN: 'plan',
EXECUTION: 'exec',
} as const;
/**
* Generate a unique ID with the specified prefix
*/
export function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Format duration for display
*/
export function formatDuration(milliseconds: number): string {
if (milliseconds < 1000) {
return `${Math.round(milliseconds)}ms`;
} else if (milliseconds < 60000) {
return `${(milliseconds / 1000).toFixed(1)}s`;
} else {
const minutes = Math.floor(milliseconds / 60000);
const seconds = Math.floor((milliseconds % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
}
/**
* Truncate string for display
*/
export function truncateString(str: string, maxLength: number = LIMITS.MAX_STRING_DISPLAY_LENGTH): string {
if (str.length <= maxLength) {
return str;
}
return `${str.substring(0, maxLength)}...`;
}

View File

@ -0,0 +1,634 @@
/**
* Tool Error Recovery System
*
* Implements robust error recovery for tool failures including retry logic,
* circuit breaker pattern, and user-friendly error handling.
*/
import log from '../../log.js';
import type { ToolCall, ToolHandler } from './tool_interfaces.js';
import {
TIMING,
LIMITS,
ERROR_PATTERNS,
TOOL_ALTERNATIVES,
TOOL_NAMES
} from './tool_constants.js';
/**
* Error types for tool execution
*/
export enum ToolErrorType {
NETWORK = 'network',
TIMEOUT = 'timeout',
VALIDATION = 'validation',
PERMISSION = 'permission',
RATE_LIMIT = 'rate_limit',
NOT_FOUND = 'not_found',
INTERNAL = 'internal',
UNKNOWN = 'unknown'
}
/**
* Tool error with categorization
*/
export interface ToolError {
type: ToolErrorType;
message: string;
originalError?: Error;
retryable: boolean;
userMessage: string;
suggestions?: string[];
context?: Record<string, unknown>;
}
/**
* Retry configuration
*/
export interface RetryConfig {
maxAttempts: number;
initialDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
jitterMs: number;
retryableErrors: ToolErrorType[];
}
/**
* Circuit breaker state
*/
export enum CircuitState {
CLOSED = 'closed',
OPEN = 'open',
HALF_OPEN = 'half_open'
}
/**
* Circuit breaker configuration
*/
export interface CircuitBreakerConfig {
failureThreshold: number;
successThreshold: number;
timeout: number;
halfOpenRequests: number;
}
/**
* Tool execution result with error recovery
*/
export interface ToolExecutionResult<T = any> {
success: boolean;
data?: T;
error?: ToolError;
attempts: number;
totalDuration: number;
recovered: boolean;
}
/**
* Recovery action
*/
export interface RecoveryAction {
type: 'retry' | 'modify' | 'alternative' | 'skip' | 'abort';
description: string;
action?: () => Promise<any>;
modifiedParameters?: Record<string, unknown>;
alternativeTool?: string;
}
/**
* Default retry configuration
*/
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxAttempts: LIMITS.MAX_RETRY_ATTEMPTS,
initialDelayMs: TIMING.RETRY_INITIAL_DELAY,
maxDelayMs: TIMING.RETRY_MAX_DELAY,
backoffMultiplier: LIMITS.RETRY_BACKOFF_MULTIPLIER,
jitterMs: TIMING.RETRY_JITTER,
retryableErrors: [
ToolErrorType.NETWORK,
ToolErrorType.TIMEOUT,
ToolErrorType.RATE_LIMIT,
ToolErrorType.INTERNAL
]
};
/**
* Default circuit breaker configuration
*/
const DEFAULT_CIRCUIT_CONFIG: CircuitBreakerConfig = {
failureThreshold: LIMITS.CIRCUIT_FAILURE_THRESHOLD,
successThreshold: LIMITS.CIRCUIT_SUCCESS_THRESHOLD,
timeout: TIMING.CIRCUIT_BREAKER_TIMEOUT,
halfOpenRequests: LIMITS.CIRCUIT_HALF_OPEN_REQUESTS
};
/**
* Circuit breaker for a tool
*/
class CircuitBreaker {
private state: CircuitState = CircuitState.CLOSED;
private failureCount: number = 0;
private successCount: number = 0;
private lastFailureTime?: Date;
private halfOpenAttempts: number = 0;
constructor(
private toolName: string,
private config: CircuitBreakerConfig
) {}
/**
* Check if the circuit allows execution
*/
public canExecute(): boolean {
switch (this.state) {
case CircuitState.CLOSED:
return true;
case CircuitState.OPEN:
// Check if timeout has passed
if (this.lastFailureTime) {
const timeSinceFailure = Date.now() - this.lastFailureTime.getTime();
if (timeSinceFailure >= this.config.timeout) {
this.state = CircuitState.HALF_OPEN;
this.halfOpenAttempts = 0;
log.info(`Circuit breaker for ${this.toolName} moved to HALF_OPEN state`);
return true;
}
}
return false;
case CircuitState.HALF_OPEN:
return this.halfOpenAttempts < this.config.halfOpenRequests;
}
}
/**
* Record a successful execution
*/
public recordSuccess(): void {
switch (this.state) {
case CircuitState.CLOSED:
// Nothing to do
break;
case CircuitState.HALF_OPEN:
this.successCount++;
if (this.successCount >= this.config.successThreshold) {
this.state = CircuitState.CLOSED;
this.failureCount = 0;
this.successCount = 0;
log.info(`Circuit breaker for ${this.toolName} moved to CLOSED state`);
}
break;
case CircuitState.OPEN:
// Should not happen
log.info(`Unexpected success recorded in OPEN state for ${this.toolName}`);
break;
}
}
/**
* Record a failed execution
*/
public recordFailure(): void {
this.lastFailureTime = new Date();
switch (this.state) {
case CircuitState.CLOSED:
this.failureCount++;
if (this.failureCount >= this.config.failureThreshold) {
this.state = CircuitState.OPEN;
log.info(`Circuit breaker for ${this.toolName} moved to OPEN state after ${this.failureCount} failures`);
}
break;
case CircuitState.HALF_OPEN:
this.halfOpenAttempts++;
this.state = CircuitState.OPEN;
this.successCount = 0;
log.info(`Circuit breaker for ${this.toolName} moved back to OPEN state`);
break;
case CircuitState.OPEN:
// Already open
break;
}
}
/**
* Get current state
*/
public getState(): CircuitState {
return this.state;
}
/**
* Reset the circuit breaker
*/
public reset(): void {
this.state = CircuitState.CLOSED;
this.failureCount = 0;
this.successCount = 0;
this.lastFailureTime = undefined;
this.halfOpenAttempts = 0;
}
}
/**
* Tool Error Recovery Manager
*/
export class ToolErrorRecoveryManager {
private retryConfig: RetryConfig;
private circuitBreakerConfig: CircuitBreakerConfig;
private circuitBreakers: Map<string, CircuitBreaker> = new Map();
private errorHistory: Map<string, ToolError[]> = new Map();
private maxErrorHistorySize: number = LIMITS.MAX_ERROR_HISTORY_SIZE;
constructor(
retryConfig?: Partial<RetryConfig>,
circuitBreakerConfig?: Partial<CircuitBreakerConfig>
) {
this.retryConfig = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
this.circuitBreakerConfig = { ...DEFAULT_CIRCUIT_CONFIG, ...circuitBreakerConfig };
}
/**
* Execute a tool with error recovery
*/
public async executeWithRecovery<T = any>(
toolCall: ToolCall,
handler: ToolHandler,
onRetry?: (attempt: number, delay: number) => void
): Promise<ToolExecutionResult<T>> {
const toolName = toolCall.function.name;
const startTime = Date.now();
// Get or create circuit breaker
let circuitBreaker = this.circuitBreakers.get(toolName);
if (!circuitBreaker) {
circuitBreaker = new CircuitBreaker(toolName, this.circuitBreakerConfig);
this.circuitBreakers.set(toolName, circuitBreaker);
}
// Check circuit breaker
if (!circuitBreaker.canExecute()) {
const error: ToolError = {
type: ToolErrorType.INTERNAL,
message: `Circuit breaker is open for ${toolName}`,
retryable: false,
userMessage: 'This tool is temporarily unavailable due to repeated failures',
suggestions: ['Try again later', 'Use an alternative approach']
};
this.recordError(toolName, error);
return {
success: false,
error,
attempts: 0,
totalDuration: Date.now() - startTime,
recovered: false
};
}
// Parse arguments
const args = typeof toolCall.function.arguments === 'string'
? JSON.parse(toolCall.function.arguments)
: toolCall.function.arguments;
let lastError: ToolError | undefined;
let attempts = 0;
// Retry loop
for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt++) {
attempts = attempt;
try {
// Execute the tool
const result = await handler.execute(args);
// Record success
circuitBreaker.recordSuccess();
return {
success: true,
data: result as T,
attempts,
totalDuration: Date.now() - startTime,
recovered: attempt > 1
};
} catch (error: any) {
// Categorize the error
const toolError = this.categorizeError(error);
lastError = toolError;
log.info(`Tool ${toolName} failed (attempt ${attempt}/${this.retryConfig.maxAttempts}): ${toolError.message}`);
// Check if error is retryable
if (!toolError.retryable || !this.retryConfig.retryableErrors.includes(toolError.type)) {
circuitBreaker.recordFailure();
this.recordError(toolName, toolError);
break;
}
// Check if we have more attempts
if (attempt < this.retryConfig.maxAttempts) {
const delay = this.calculateRetryDelay(attempt);
if (onRetry) {
onRetry(attempt, delay);
}
log.info(`Retrying ${toolName} after ${delay}ms...`);
await this.sleep(delay);
} else {
// No more attempts
circuitBreaker.recordFailure();
this.recordError(toolName, toolError);
}
}
}
// All attempts failed
return {
success: false,
error: lastError,
attempts,
totalDuration: Date.now() - startTime,
recovered: false
};
}
/**
* Categorize an error
*/
public categorizeError(error: any): ToolError {
const message = error.message || String(error);
// Network errors
if (ERROR_PATTERNS.NETWORK.some(pattern => message.includes(pattern))) {
return {
type: ToolErrorType.NETWORK,
message,
originalError: error,
retryable: true,
userMessage: 'Network connection error. Please check your internet connection.',
suggestions: ['Check network connectivity', 'Verify service availability']
};
}
// Timeout errors
if (ERROR_PATTERNS.TIMEOUT.some(pattern => message.includes(pattern))) {
return {
type: ToolErrorType.TIMEOUT,
message,
originalError: error,
retryable: true,
userMessage: 'The operation took too long to complete.',
suggestions: ['Try again with smaller data', 'Check system performance']
};
}
// Rate limit errors
if (ERROR_PATTERNS.RATE_LIMIT.some(pattern => message.includes(pattern))) {
return {
type: ToolErrorType.RATE_LIMIT,
message,
originalError: error,
retryable: true,
userMessage: 'Too many requests. Please wait a moment.',
suggestions: ['Wait before retrying', 'Reduce request frequency']
};
}
// Permission errors
if (ERROR_PATTERNS.PERMISSION.some(pattern => message.includes(pattern))) {
return {
type: ToolErrorType.PERMISSION,
message,
originalError: error,
retryable: false,
userMessage: 'Permission denied. Please check your credentials.',
suggestions: ['Verify API keys', 'Check access permissions']
};
}
// Not found errors
if (ERROR_PATTERNS.NOT_FOUND.some(pattern => message.includes(pattern))) {
return {
type: ToolErrorType.NOT_FOUND,
message,
originalError: error,
retryable: false,
userMessage: 'The requested resource was not found.',
suggestions: ['Verify the resource ID', 'Check if resource was deleted']
};
}
// Validation errors
if (ERROR_PATTERNS.VALIDATION.some(pattern => message.includes(pattern))) {
return {
type: ToolErrorType.VALIDATION,
message,
originalError: error,
retryable: false,
userMessage: 'Invalid input parameters.',
suggestions: ['Check input format', 'Verify required fields']
};
}
// Internal errors
if (ERROR_PATTERNS.INTERNAL.some(pattern => message.includes(pattern))) {
return {
type: ToolErrorType.INTERNAL,
message,
originalError: error,
retryable: true,
userMessage: 'An internal error occurred.',
suggestions: ['Try again later', 'Contact support if issue persists']
};
}
// Unknown errors
return {
type: ToolErrorType.UNKNOWN,
message,
originalError: error,
retryable: true,
userMessage: 'An unexpected error occurred.',
suggestions: ['Try again', 'Check logs for details']
};
}
/**
* Suggest recovery actions for an error
*/
public suggestRecoveryActions(
toolName: string,
error: ToolError,
parameters: Record<string, unknown>
): RecoveryAction[] {
const actions: RecoveryAction[] = [];
// Retry action for retryable errors
if (error.retryable) {
actions.push({
type: 'retry',
description: 'Retry the operation',
action: async () => {
// Implementation would retry with same parameters
return null;
}
});
}
// Suggest parameter modifications based on error type
if (error.type === ToolErrorType.VALIDATION) {
actions.push({
type: 'modify',
description: 'Modify parameters and retry',
modifiedParameters: this.suggestParameterModifications(toolName, parameters, error)
});
}
// Suggest alternative tools
const alternativeTool = this.suggestAlternativeTool(toolName, error);
if (alternativeTool) {
actions.push({
type: 'alternative',
description: `Use ${alternativeTool} instead`,
alternativeTool
});
}
// Skip action
actions.push({
type: 'skip',
description: 'Skip this operation and continue'
});
// Abort action for critical errors
if (error.type === ToolErrorType.PERMISSION || !error.retryable) {
actions.push({
type: 'abort',
description: 'Abort the entire operation'
});
}
return actions;
}
/**
* Suggest parameter modifications
*/
private suggestParameterModifications(
toolName: string,
parameters: Record<string, unknown>,
error: ToolError
): Record<string, unknown> {
const modified = { ...parameters };
// Tool-specific modifications
if (toolName === TOOL_NAMES.SEARCH_NOTES && error.message.includes('limit')) {
modified.limit = Math.min((parameters.limit as number) || 10, 5);
}
if (toolName === TOOL_NAMES.WEB_SEARCH && error.type === ToolErrorType.TIMEOUT) {
modified.timeout = TIMING.RETRY_MAX_DELAY; // Increase timeout
}
return modified;
}
/**
* Suggest alternative tool
*/
private suggestAlternativeTool(toolName: string, error: ToolError): string | undefined {
const toolAlternatives = TOOL_ALTERNATIVES[toolName];
if (toolAlternatives && toolAlternatives.length > 0) {
return toolAlternatives[0];
}
return undefined;
}
/**
* Calculate retry delay with exponential backoff and jitter
*/
private calculateRetryDelay(attempt: number): number {
const exponentialDelay = Math.min(
this.retryConfig.initialDelayMs * Math.pow(this.retryConfig.backoffMultiplier, attempt - 1),
this.retryConfig.maxDelayMs
);
// Add jitter to prevent thundering herd
const jitter = Math.random() * this.retryConfig.jitterMs - this.retryConfig.jitterMs / 2;
return Math.max(0, exponentialDelay + jitter);
}
/**
* Record an error for history
*/
private recordError(toolName: string, error: ToolError): void {
if (!this.errorHistory.has(toolName)) {
this.errorHistory.set(toolName, []);
}
const errors = this.errorHistory.get(toolName)!;
errors.unshift(error);
// Trim history
if (errors.length > this.maxErrorHistorySize) {
errors.splice(this.maxErrorHistorySize);
}
}
/**
* Get error history for a tool
*/
public getErrorHistory(toolName: string): ToolError[] {
return this.errorHistory.get(toolName) || [];
}
/**
* Get circuit breaker state
*/
public getCircuitBreakerState(toolName: string): CircuitState | undefined {
const breaker = this.circuitBreakers.get(toolName);
return breaker?.getState();
}
/**
* Reset circuit breaker for a tool
*/
public resetCircuitBreaker(toolName: string): void {
const breaker = this.circuitBreakers.get(toolName);
if (breaker) {
breaker.reset();
log.info(`Reset circuit breaker for ${toolName}`);
}
}
/**
* Sleep for specified milliseconds
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Clear all error history
*/
public clearHistory(): void {
this.errorHistory.clear();
}
}
// Export singleton instance
export const toolErrorRecoveryManager = new ToolErrorRecoveryManager();
export default toolErrorRecoveryManager;

View File

@ -0,0 +1,588 @@
/**
* Real-time Tool Feedback System
*
* Provides real-time feedback during tool execution including progress updates,
* intermediate results, and execution history tracking.
*/
import { EventEmitter } from 'events';
import log from '../../log.js';
import type { ToolCall } from './tool_interfaces.js';
import {
TIMING,
LIMITS,
ID_PREFIXES,
generateId,
formatDuration
} from './tool_constants.js';
/**
* Tool execution status
*/
export type ToolExecutionStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled' | 'timeout';
/**
* Tool execution step
*/
export interface ToolExecutionStep {
timestamp: Date;
message: string;
type: 'info' | 'warning' | 'error' | 'progress';
data?: any;
}
/**
* Tool execution progress
*/
export interface ToolExecutionProgress {
current: number;
total: number;
percentage: number;
message?: string;
estimatedTimeRemaining?: number;
}
/**
* Tool execution record
*/
export interface ToolExecutionRecord {
id: string;
toolName: string;
parameters: Record<string, unknown>;
status: ToolExecutionStatus;
startTime: Date;
endTime?: Date;
duration?: number;
steps: ToolExecutionStep[];
progress?: ToolExecutionProgress;
result?: any;
error?: string;
cancelledBy?: string;
cancelReason?: string;
}
/**
* Tool execution history entry
*/
export interface ToolExecutionHistoryEntry {
id: string;
chatNoteId?: string;
toolName: string;
status: ToolExecutionStatus;
startTime: Date;
endTime?: Date;
duration?: number;
parameters: Record<string, unknown>;
result?: any;
error?: string;
}
/**
* Tool feedback events
*/
export interface ToolFeedbackEvents {
'execution:start': (record: ToolExecutionRecord) => void;
'execution:progress': (id: string, progress: ToolExecutionProgress) => void;
'execution:step': (id: string, step: ToolExecutionStep) => void;
'execution:complete': (record: ToolExecutionRecord) => void;
'execution:error': (id: string, error: string) => void;
'execution:cancelled': (id: string, reason?: string) => void;
'execution:timeout': (id: string) => void;
}
/**
* Tool Feedback Manager
*/
export class ToolFeedbackManager extends EventEmitter {
private activeExecutions: Map<string, ToolExecutionRecord> = new Map();
private executionHistory: ToolExecutionHistoryEntry[] = [];
private maxHistorySize: number = LIMITS.MAX_HISTORY_SIZE;
private executionTimeouts: Map<string, NodeJS.Timeout> = new Map();
private defaultTimeout: number = TIMING.DEFAULT_TOOL_TIMEOUT;
constructor() {
super();
this.setMaxListeners(LIMITS.MAX_EVENT_LISTENERS); // Allow many listeners for concurrent executions
}
/**
* Start tracking a tool execution
*/
public startExecution(
toolCall: ToolCall,
timeout?: number
): string {
const executionId = toolCall.id || generateId(ID_PREFIXES.EXECUTION);
const parameters = typeof toolCall.function.arguments === 'string'
? JSON.parse(toolCall.function.arguments)
: toolCall.function.arguments;
const record: ToolExecutionRecord = {
id: executionId,
toolName: toolCall.function.name,
parameters,
status: 'pending',
startTime: new Date(),
steps: []
};
this.activeExecutions.set(executionId, record);
// Set execution timeout
const timeoutMs = timeout || this.defaultTimeout;
const timeoutId = setTimeout(() => {
this.handleTimeout(executionId);
}, timeoutMs);
this.executionTimeouts.set(executionId, timeoutId);
// Update status to running
record.status = 'running';
this.addStep(executionId, {
timestamp: new Date(),
message: `Starting execution of ${toolCall.function.name}`,
type: 'info'
});
this.emit('execution:start', record);
log.info(`Started tracking execution ${executionId} for tool ${toolCall.function.name}`);
return executionId;
}
/**
* Update execution progress
*/
public updateProgress(
executionId: string,
current: number,
total: number,
message?: string
): void {
const record = this.activeExecutions.get(executionId);
if (!record) {
log.info(`Execution ${executionId} not found for progress update`);
return;
}
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
// Calculate estimated time remaining based on current progress
let estimatedTimeRemaining: number | undefined;
if (record.startTime && percentage > 0 && percentage < 100) {
const elapsedMs = Date.now() - record.startTime.getTime();
const estimatedTotalMs = (elapsedMs / percentage) * 100;
estimatedTimeRemaining = Math.round(estimatedTotalMs - elapsedMs);
}
const progress: ToolExecutionProgress = {
current,
total,
percentage,
message,
estimatedTimeRemaining
};
record.progress = progress;
this.emit('execution:progress', executionId, progress);
// Add progress step if message provided
if (message) {
this.addStep(executionId, {
timestamp: new Date(),
message: `Progress: ${message} (${percentage}%)`,
type: 'progress',
data: { current, total, percentage }
});
}
}
/**
* Add an execution step
*/
public addStep(
executionId: string,
step: ToolExecutionStep
): void {
const record = this.activeExecutions.get(executionId);
if (!record) {
log.info(`Execution ${executionId} not found for step addition`);
return;
}
record.steps.push(step);
this.emit('execution:step', executionId, step);
// Log significant steps
if (step.type === 'error' || step.type === 'warning') {
log.info(`Tool execution step [${executionId}]: ${step.message}`);
}
}
/**
* Add intermediate result
*/
public addIntermediateResult(
executionId: string,
message: string,
data?: any
): void {
this.addStep(executionId, {
timestamp: new Date(),
message,
type: 'info',
data
});
}
/**
* Complete an execution successfully
*/
public completeExecution(
executionId: string,
result?: any
): void {
const record = this.activeExecutions.get(executionId);
if (!record) {
log.info(`Execution ${executionId} not found for completion`);
return;
}
// Clear timeout
this.clearExecutionTimeout(executionId);
record.status = 'success';
record.endTime = new Date();
record.duration = record.endTime.getTime() - record.startTime.getTime();
record.result = result;
this.addStep(executionId, {
timestamp: new Date(),
message: `Completed successfully in ${formatDuration(record.duration)}`,
type: 'info',
data: result
});
this.emit('execution:complete', record);
this.moveToHistory(record);
this.activeExecutions.delete(executionId);
log.info(`Completed execution ${executionId} for tool ${record.toolName} in ${record.duration}ms`);
}
/**
* Mark an execution as failed
*/
public failExecution(
executionId: string,
error: string
): void {
const record = this.activeExecutions.get(executionId);
if (!record) {
log.info(`Execution ${executionId} not found for failure`);
return;
}
// Clear timeout
this.clearExecutionTimeout(executionId);
record.status = 'error';
record.endTime = new Date();
record.duration = record.endTime.getTime() - record.startTime.getTime();
record.error = error;
this.addStep(executionId, {
timestamp: new Date(),
message: `Failed: ${error}`,
type: 'error'
});
this.emit('execution:error', executionId, error);
this.moveToHistory(record);
this.activeExecutions.delete(executionId);
log.error(`Failed execution ${executionId} for tool ${record.toolName}: ${error}`);
}
/**
* Cancel an execution
*/
public cancelExecution(
executionId: string,
cancelledBy?: string,
reason?: string
): boolean {
const record = this.activeExecutions.get(executionId);
if (!record) {
log.info(`Execution ${executionId} not found for cancellation`);
return false;
}
if (record.status !== 'running' && record.status !== 'pending') {
log.info(`Cannot cancel execution ${executionId} with status ${record.status}`);
return false;
}
// Clear timeout
this.clearExecutionTimeout(executionId);
record.status = 'cancelled';
record.endTime = new Date();
record.duration = record.endTime.getTime() - record.startTime.getTime();
record.cancelledBy = cancelledBy;
record.cancelReason = reason;
this.addStep(executionId, {
timestamp: new Date(),
message: `Cancelled${cancelledBy ? ` by ${cancelledBy}` : ''}${reason ? `: ${reason}` : ''}`,
type: 'warning'
});
this.emit('execution:cancelled', executionId, reason);
this.moveToHistory(record);
this.activeExecutions.delete(executionId);
log.info(`Cancelled execution ${executionId} for tool ${record.toolName}`);
return true;
}
/**
* Handle execution timeout
*/
private handleTimeout(executionId: string): void {
const record = this.activeExecutions.get(executionId);
if (!record || record.status !== 'running') {
return;
}
record.status = 'timeout';
record.endTime = new Date();
record.duration = record.endTime.getTime() - record.startTime.getTime();
this.addStep(executionId, {
timestamp: new Date(),
message: `Execution timed out after ${formatDuration(record.duration)}`,
type: 'error'
});
this.emit('execution:timeout', executionId);
this.moveToHistory(record);
this.activeExecutions.delete(executionId);
this.executionTimeouts.delete(executionId);
log.error(`Execution ${executionId} for tool ${record.toolName} timed out`);
}
/**
* Clear execution timeout
*/
private clearExecutionTimeout(executionId: string): void {
const timeoutId = this.executionTimeouts.get(executionId);
if (timeoutId) {
clearTimeout(timeoutId);
this.executionTimeouts.delete(executionId);
}
}
/**
* Move execution record to history
*/
private moveToHistory(record: ToolExecutionRecord): void {
const historyEntry: ToolExecutionHistoryEntry = {
id: record.id,
toolName: record.toolName,
status: record.status,
startTime: record.startTime,
endTime: record.endTime,
duration: record.duration,
parameters: record.parameters,
result: record.result,
error: record.error
};
this.executionHistory.unshift(historyEntry);
// Trim history if needed
if (this.executionHistory.length > this.maxHistorySize) {
this.executionHistory = this.executionHistory.slice(0, this.maxHistorySize);
}
}
/**
* Get active executions
*/
public getActiveExecutions(): ToolExecutionRecord[] {
return Array.from(this.activeExecutions.values());
}
/**
* Get execution by ID
*/
public getExecution(executionId: string): ToolExecutionRecord | undefined {
return this.activeExecutions.get(executionId);
}
/**
* Get execution history
*/
public getHistory(
filter?: {
toolName?: string;
status?: ToolExecutionStatus;
chatNoteId?: string;
limit?: number;
}
): ToolExecutionHistoryEntry[] {
let history = [...this.executionHistory];
if (filter) {
if (filter.toolName) {
history = history.filter(h => h.toolName === filter.toolName);
}
if (filter.status) {
history = history.filter(h => h.status === filter.status);
}
if (filter.chatNoteId) {
history = history.filter(h => h.chatNoteId === filter.chatNoteId);
}
if (filter.limit) {
history = history.slice(0, filter.limit);
}
}
return history;
}
/**
* Get execution statistics
*/
public getStatistics(): {
totalExecutions: number;
successfulExecutions: number;
failedExecutions: number;
cancelledExecutions: number;
timeoutExecutions: number;
averageDuration: number;
toolStatistics: Record<string, {
count: number;
successRate: number;
averageDuration: number;
}>;
} {
const stats = this.initializeStatistics();
this.calculateOverallStatistics(stats);
this.calculateToolStatistics(stats);
return stats;
}
/**
* Initialize statistics object
*/
private initializeStatistics(): any {
return {
totalExecutions: this.executionHistory.length,
successfulExecutions: 0,
failedExecutions: 0,
cancelledExecutions: 0,
timeoutExecutions: 0,
averageDuration: 0,
toolStatistics: {}
};
}
/**
* Calculate overall statistics
*/
private calculateOverallStatistics(stats: any): void {
let totalDuration = 0;
let durationCount = 0;
for (const entry of this.executionHistory) {
// Count by status
this.incrementStatusCount(stats, entry.status);
// Track durations
if (entry.duration) {
totalDuration += entry.duration;
durationCount++;
}
// Initialize per-tool statistics
if (!stats.toolStatistics[entry.toolName]) {
stats.toolStatistics[entry.toolName] = {
count: 0,
successRate: 0,
averageDuration: 0
};
}
stats.toolStatistics[entry.toolName].count++;
}
// Calculate average duration
stats.averageDuration = durationCount > 0
? Math.round(totalDuration / durationCount)
: 0;
}
/**
* Increment status count
*/
private incrementStatusCount(stats: any, status: ToolExecutionStatus): void {
switch (status) {
case 'success':
stats.successfulExecutions++;
break;
case 'error':
stats.failedExecutions++;
break;
case 'cancelled':
stats.cancelledExecutions++;
break;
case 'timeout':
stats.timeoutExecutions++;
break;
}
}
/**
* Calculate per-tool statistics
*/
private calculateToolStatistics(stats: any): void {
for (const toolName of Object.keys(stats.toolStatistics)) {
const toolEntries = this.executionHistory.filter(e => e.toolName === toolName);
const successCount = toolEntries.filter(e => e.status === 'success').length;
const toolDurations = toolEntries
.filter(e => e.duration)
.map(e => e.duration!);
stats.toolStatistics[toolName].successRate =
toolEntries.length > 0
? Math.round((successCount / toolEntries.length) * 100)
: 0;
stats.toolStatistics[toolName].averageDuration =
toolDurations.length > 0
? Math.round(toolDurations.reduce((a, b) => a + b, 0) / toolDurations.length)
: 0;
}
}
/**
* Clear all execution data
*/
public clear(): void {
// Cancel all active executions
for (const executionId of this.activeExecutions.keys()) {
this.cancelExecution(executionId, 'system', 'System cleanup');
}
this.activeExecutions.clear();
this.executionHistory = [];
this.executionTimeouts.clear();
}
}
// Export singleton instance
export const toolFeedbackManager = new ToolFeedbackManager();
export default toolFeedbackManager;

View File

@ -0,0 +1,299 @@
/**
* Tool Preview System
*
* Provides preview functionality for tool calls before execution,
* allowing users to review and approve/reject tool operations.
*/
import type { Tool, ToolCall, ToolHandler } from './tool_interfaces.js';
import log from '../../log.js';
import {
TOOL_DISPLAY_NAMES,
TOOL_DESCRIPTIONS,
TOOL_ESTIMATED_DURATIONS,
TOOL_RISK_LEVELS,
TOOL_WARNINGS,
SENSITIVE_OPERATIONS,
TIMING,
LIMITS,
ID_PREFIXES,
generateId,
truncateString
} from './tool_constants.js';
/**
* Tool preview information
*/
export interface ToolPreview {
id: string;
toolName: string;
displayName: string;
description: string;
parameters: Record<string, unknown>;
formattedParameters: string[];
estimatedDuration: number;
riskLevel: 'low' | 'medium' | 'high';
requiresConfirmation: boolean;
warnings?: string[];
}
/**
* Tool execution plan
*/
export interface ToolExecutionPlan {
id: string;
tools: ToolPreview[];
totalEstimatedDuration: number;
requiresConfirmation: boolean;
createdAt: Date;
}
/**
* Tool approval status
*/
export interface ToolApproval {
planId: string;
approved: boolean;
rejectedTools?: string[];
modifiedParameters?: Record<string, Record<string, unknown>>;
approvedAt?: Date;
approvedBy?: string;
}
/**
* Tool preview configuration
*/
interface ToolPreviewConfig {
requireConfirmationForSensitive: boolean;
sensitiveOperations: string[];
estimatedDurations: Record<string, number>;
riskLevels: Record<string, 'low' | 'medium' | 'high'>;
}
/**
* Default configuration for tool previews
*/
const DEFAULT_CONFIG: ToolPreviewConfig = {
requireConfirmationForSensitive: true,
sensitiveOperations: [...SENSITIVE_OPERATIONS],
estimatedDurations: { ...TOOL_ESTIMATED_DURATIONS },
riskLevels: { ...TOOL_RISK_LEVELS }
};
/**
* Tool Preview Manager
*/
export class ToolPreviewManager {
private config: ToolPreviewConfig;
private executionPlans: Map<string, ToolExecutionPlan> = new Map();
private approvals: Map<string, ToolApproval> = new Map();
constructor(config?: Partial<ToolPreviewConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Create a preview for a single tool call
*/
public createToolPreview(toolCall: ToolCall, handler?: ToolHandler): ToolPreview {
const toolName = toolCall.function.name;
const parameters = typeof toolCall.function.arguments === 'string'
? JSON.parse(toolCall.function.arguments)
: toolCall.function.arguments;
const preview: ToolPreview = {
id: toolCall.id || generateId(ID_PREFIXES.PREVIEW),
toolName,
displayName: this.getDisplayName(toolName),
description: this.getToolDescription(toolName, handler),
parameters,
formattedParameters: this.formatParameters(parameters, handler),
estimatedDuration: this.getEstimatedDuration(toolName),
riskLevel: this.getRiskLevel(toolName),
requiresConfirmation: this.requiresConfirmation(toolName),
warnings: this.getWarnings(toolName, parameters)
};
return preview;
}
/**
* Create an execution plan for multiple tool calls
*/
public createExecutionPlan(toolCalls: ToolCall[], handlers?: Map<string, ToolHandler>): ToolExecutionPlan {
const planId = generateId(ID_PREFIXES.PLAN);
const tools: ToolPreview[] = [];
let totalDuration = 0;
let requiresConfirmation = false;
for (const toolCall of toolCalls) {
const handler = handlers?.get(toolCall.function.name);
const preview = this.createToolPreview(toolCall, handler);
tools.push(preview);
totalDuration += preview.estimatedDuration;
if (preview.requiresConfirmation) {
requiresConfirmation = true;
}
}
const plan: ToolExecutionPlan = {
id: planId,
tools,
totalEstimatedDuration: totalDuration,
requiresConfirmation,
createdAt: new Date()
};
this.executionPlans.set(planId, plan);
return plan;
}
/**
* Get a stored execution plan
*/
public getExecutionPlan(planId: string): ToolExecutionPlan | undefined {
return this.executionPlans.get(planId);
}
/**
* Record tool approval
*/
public recordApproval(approval: ToolApproval): void {
approval.approvedAt = new Date();
this.approvals.set(approval.planId, approval);
log.info(`Tool execution plan ${approval.planId} ${approval.approved ? 'approved' : 'rejected'}`);
}
/**
* Get approval for a plan
*/
public getApproval(planId: string): ToolApproval | undefined {
return this.approvals.get(planId);
}
/**
* Check if a plan is approved
*/
public isPlanApproved(planId: string): boolean {
const approval = this.approvals.get(planId);
return approval?.approved === true;
}
/**
* Get display name for a tool
*/
private getDisplayName(toolName: string): string {
return TOOL_DISPLAY_NAMES[toolName] || toolName;
}
/**
* Get tool description
*/
private getToolDescription(toolName: string, handler?: ToolHandler): string {
if (handler?.definition.function.description) {
return handler.definition.function.description;
}
return TOOL_DESCRIPTIONS[toolName] || 'Execute tool operation';
}
/**
* Format parameters for display
*/
private formatParameters(parameters: Record<string, unknown>, handler?: ToolHandler): string[] {
const formatted: string[] = [];
for (const [key, value] of Object.entries(parameters)) {
let displayValue: string;
if (value === null || value === undefined) {
displayValue = 'none';
} else if (typeof value === 'string') {
// Truncate long strings
displayValue = `"${truncateString(value, LIMITS.MAX_STRING_DISPLAY_LENGTH)}"`;
} else if (Array.isArray(value)) {
displayValue = `[${value.length} items]`;
} else if (typeof value === 'object') {
displayValue = '{object}';
} else {
displayValue = String(value);
}
// Get parameter description from handler if available
const paramDef = handler?.definition.function.parameters.properties[key];
const description = paramDef?.description || '';
formatted.push(`${key}: ${displayValue}${description ? ` (${description})` : ''}`);
}
return formatted;
}
/**
* Get estimated duration for a tool
*/
private getEstimatedDuration(toolName: string): number {
return this.config.estimatedDurations[toolName] || 1000;
}
/**
* Get risk level for a tool
*/
private getRiskLevel(toolName: string): 'low' | 'medium' | 'high' {
return this.config.riskLevels[toolName] || 'low';
}
/**
* Check if tool requires confirmation
*/
private requiresConfirmation(toolName: string): boolean {
if (!this.config.requireConfirmationForSensitive) {
return false;
}
return this.config.sensitiveOperations.includes(toolName);
}
/**
* Get warnings for a tool call
*/
private getWarnings(toolName: string, parameters: Record<string, unknown>): string[] | undefined {
const warnings: string[] = [];
// Add predefined warnings
const predefinedWarnings = TOOL_WARNINGS[toolName];
if (predefinedWarnings) {
warnings.push(...predefinedWarnings);
}
// Add dynamic warnings based on parameters
if (toolName === 'update_note' && parameters.content) {
const content = String(parameters.content);
if (content.length > LIMITS.LARGE_CONTENT_THRESHOLD) {
warnings.push('Large content update may take longer');
}
}
return warnings.length > 0 ? warnings : undefined;
}
/**
* Clean up old execution plans
*/
public cleanup(maxAgeMs: number = TIMING.CLEANUP_MAX_AGE): void {
const now = Date.now();
const cutoff = new Date(now - maxAgeMs);
for (const [planId, plan] of this.executionPlans.entries()) {
if (plan.createdAt < cutoff) {
this.executionPlans.delete(planId);
this.approvals.delete(planId);
}
}
log.info(`Cleaned up execution plans older than ${maxAgeMs}ms`);
}
}
// Export singleton instance
export const toolPreviewManager = new ToolPreviewManager();
export default toolPreviewManager;

View File

@ -1,408 +0,0 @@
/**
* Workflow Helper Tool
*
* This tool helps LLMs understand and execute multi-step workflows by providing
* smart guidance on tool chaining and next steps.
*/
import type { Tool, ToolHandler } from './tool_interfaces.js';
import log from '../../log.js';
/**
* Definition of the workflow helper tool
*/
export const workflowHelperDefinition: Tool = {
type: 'function',
function: {
name: 'workflow_helper',
description: `WORKFLOW GUIDANCE for multi-step tasks. Get smart suggestions for tool chaining and next steps.
BEST FOR: Planning complex workflows, understanding tool sequences, getting unstuck
USE WHEN: You need to do multiple operations, aren't sure what to do next, or want workflow optimization
HELPS WITH: Tool sequencing, parameter passing, workflow planning
TIP: Use this when you have partial results and need guidance on next steps
NEXT STEPS: Follow the recommended workflow steps provided`,
parameters: {
type: 'object',
properties: {
currentStep: {
type: 'string',
description: `📍 DESCRIBE YOUR CURRENT STEP: What have you just done or what results do you have?
GOOD EXAMPLES:
- "I just found 5 notes about machine learning using search_notes"
- "I have a noteId abc123def456 and want to modify it"
- "I searched but got no results"
- "I created a new note and want to organize it"
💡 Be specific about your current state and what you've accomplished`
},
goal: {
type: 'string',
description: `🎯 FINAL GOAL: What are you ultimately trying to accomplish?
EXAMPLES:
- "Find and read all notes about a specific project"
- "Create a comprehensive summary of all my research notes"
- "Organize all my TODO notes by priority"
- "Find related notes and create connections between them"`
},
availableData: {
type: 'string',
description: `📊 AVAILABLE DATA: What noteIds, search results, or other data do you currently have?
EXAMPLES:
- "noteIds: abc123, def456, ghi789"
- "Search results with 3 notes about project management"
- "Empty search results for machine learning"
- "Just created noteId xyz999"`
},
includeExamples: {
type: 'boolean',
description: '📚 INCLUDE EXAMPLES: Get specific command examples for next steps (default: true)'
}
},
required: ['currentStep', 'goal']
}
}
};
/**
* Workflow helper implementation
*/
export class WorkflowHelper implements ToolHandler {
public definition: Tool = workflowHelperDefinition;
/**
* Common workflow patterns
*/
private getWorkflowPatterns(): Record<string, {
name: string;
description: string;
steps: string[];
examples: string[];
}> {
return {
'search_read_analyze': {
name: '🔍➡️📖➡️🧠 Search → Read → Analyze',
description: 'Find notes, read their content, then analyze or summarize',
steps: [
'Use search tools to find relevant notes',
'Use read_note to get full content of interesting results',
'Use note_summarization or content_extraction for analysis'
],
examples: [
'Research project: Find all research notes → Read them → Summarize findings',
'Learning topic: Search for learning materials → Read content → Extract key concepts'
]
},
'search_create_organize': {
name: '🔍➡️📝➡️🏷️ Search → Create → Organize',
description: 'Find related content, create new notes, then organize with attributes',
steps: [
'Search for related existing content',
'Create new note with note_creation',
'Add attributes/relations with attribute_manager'
],
examples: [
'New project: Find similar projects → Create project note → Tag with #project',
'Meeting notes: Search for project context → Create meeting note → Link to project'
]
},
'find_read_update': {
name: '🔍➡️📖➡️✏️ Find → Read → Update',
description: 'Find existing notes, review content, then make updates',
steps: [
'Use search tools to locate the note',
'Use read_note to see current content',
'Use note_update to make changes'
],
examples: [
'Update project status: Find project note → Read current status → Update with progress',
'Improve documentation: Find doc note → Read content → Add new information'
]
},
'organize_existing': {
name: '🔍➡️🏷️➡️🔗 Find → Tag → Connect',
description: 'Find notes that need organization, add attributes, create relationships',
steps: [
'Search for notes to organize',
'Use attribute_manager to add labels/categories',
'Use relationship tool to create connections'
],
examples: [
'Organize research: Find research notes → Tag by topic → Link related studies',
'Clean up TODOs: Find TODO notes → Tag by priority → Link to projects'
]
}
};
}
/**
* Analyze current step and recommend next actions
*/
private analyzeCurrentStep(currentStep: string, goal: string, availableData?: string): {
analysis: string;
recommendations: Array<{
action: string;
tool: string;
parameters: Record<string, any>;
reasoning: string;
priority: number;
}>;
warnings?: string[];
} {
const step = currentStep.toLowerCase();
const goalLower = goal.toLowerCase();
const recommendations: any[] = [];
const warnings: string[] = [];
// Analyze search results
if (step.includes('found') && step.includes('notes')) {
if (step.includes('no results') || step.includes('empty') || step.includes('0 notes')) {
recommendations.push({
action: 'Try alternative search approaches',
tool: 'search_notes',
parameters: { query: 'broader or alternative search terms' },
reasoning: 'Empty results suggest need for different search strategy',
priority: 1
});
recommendations.push({
action: 'Try keyword search instead',
tool: 'keyword_search_notes',
parameters: { query: 'specific keywords from your search' },
reasoning: 'Keyword search might find what semantic search missed',
priority: 2
});
warnings.push('Consider if the content might not exist yet - you may need to create it');
} else {
// Has search results
recommendations.push({
action: 'Read the most relevant notes',
tool: 'read_note',
parameters: { noteId: 'from search results', includeAttributes: true },
reasoning: 'Get full content to understand what you found',
priority: 1
});
if (goalLower.includes('summary') || goalLower.includes('analyze')) {
recommendations.push({
action: 'Summarize the content',
tool: 'note_summarization',
parameters: { noteId: 'from search results' },
reasoning: 'Goal involves analysis or summarization',
priority: 2
});
}
}
}
// Analyze note reading
if (step.includes('read') || step.includes('noteId')) {
if (goalLower.includes('update') || goalLower.includes('edit') || goalLower.includes('modify')) {
recommendations.push({
action: 'Update the note content',
tool: 'note_update',
parameters: { noteId: 'the one you just read', content: 'new content' },
reasoning: 'Goal involves modifying existing content',
priority: 1
});
}
if (goalLower.includes('organize') || goalLower.includes('tag') || goalLower.includes('categorize')) {
recommendations.push({
action: 'Add organizing attributes',
tool: 'attribute_manager',
parameters: { noteId: 'the one you read', action: 'add', attributeType: 'label' },
reasoning: 'Goal involves organization and categorization',
priority: 1
});
}
if (goalLower.includes('related') || goalLower.includes('connect') || goalLower.includes('link')) {
recommendations.push({
action: 'Search for related content',
tool: 'search_notes',
parameters: { query: 'concepts from the note you read' },
reasoning: 'Goal involves finding and connecting related content',
priority: 2
});
}
}
// Analyze creation
if (step.includes('created') || step.includes('new note')) {
recommendations.push({
action: 'Add organizing attributes',
tool: 'attribute_manager',
parameters: { noteId: 'the newly created note', action: 'add' },
reasoning: 'New notes should be organized with appropriate tags',
priority: 1
});
if (goalLower.includes('project') || goalLower.includes('research')) {
recommendations.push({
action: 'Find and link related notes',
tool: 'search_notes',
parameters: { query: 'related to your new note topic' },
reasoning: 'Connect new content to existing related materials',
priority: 2
});
}
}
return {
analysis: this.generateAnalysis(currentStep, goal, recommendations.length),
recommendations: recommendations.sort((a, b) => a.priority - b.priority),
warnings: warnings.length > 0 ? warnings : undefined
};
}
/**
* Generate workflow analysis
*/
private generateAnalysis(currentStep: string, goal: string, recommendationCount: number): string {
const patterns = this.getWorkflowPatterns();
let analysis = `📊 CURRENT STATE: ${currentStep}\n`;
analysis += `🎯 TARGET GOAL: ${goal}\n\n`;
if (recommendationCount > 0) {
analysis += `✅ I've identified ${recommendationCount} recommended next steps based on your current progress and goal.\n\n`;
} else {
analysis += `🤔 Your situation is unique. I'll provide general guidance based on common patterns.\n\n`;
}
// Suggest relevant workflow patterns
const goalLower = goal.toLowerCase();
if (goalLower.includes('read') && goalLower.includes('find')) {
analysis += `📖 PATTERN MATCH: This looks like a "${patterns.search_read_analyze.name}" workflow\n`;
} else if (goalLower.includes('create') && goalLower.includes('organize')) {
analysis += `📝 PATTERN MATCH: This looks like a "${patterns.search_create_organize.name}" workflow\n`;
} else if (goalLower.includes('update') && goalLower.includes('find')) {
analysis += `✏️ PATTERN MATCH: This looks like a "${patterns.find_read_update.name}" workflow\n`;
}
return analysis;
}
/**
* Execute the workflow helper tool
*/
public async execute(args: {
currentStep: string,
goal: string,
availableData?: string,
includeExamples?: boolean
}): Promise<string | object> {
try {
const { currentStep, goal, availableData, includeExamples = true } = args;
log.info(`Executing workflow_helper - Current: "${currentStep}", Goal: "${goal}"`);
const analysis = this.analyzeCurrentStep(currentStep, goal, availableData);
const patterns = this.getWorkflowPatterns();
// Extract noteIds from available data if provided
const noteIds = availableData ? this.extractNoteIds(availableData) : [];
const response: any = {
currentStep,
goal,
analysis: analysis.analysis,
immediateNext: analysis.recommendations.length > 0 ? {
primaryAction: analysis.recommendations[0],
alternatives: analysis.recommendations.slice(1, 3)
} : undefined,
extractedData: {
noteIds: noteIds.length > 0 ? noteIds : undefined,
hasData: !!availableData
}
};
if (analysis.warnings) {
response.warnings = {
message: '⚠️ Important considerations:',
items: analysis.warnings
};
}
if (includeExamples && analysis.recommendations.length > 0) {
response.examples = {
message: '📚 Specific tool usage examples:',
commands: analysis.recommendations.slice(0, 2).map(rec => ({
tool: rec.tool,
example: this.generateExample(rec.tool, rec.parameters, noteIds),
description: rec.reasoning
}))
};
}
// Add relevant workflow patterns
response.workflowPatterns = {
message: '🔄 Common workflow patterns you might find useful:',
patterns: Object.values(patterns).slice(0, 2).map(pattern => ({
name: pattern.name,
description: pattern.description,
steps: pattern.steps
}))
};
response.tips = [
'💡 Use the noteId values from search results, not note titles',
'🔄 Check tool results carefully before proceeding to next step',
'📊 Use workflow_helper again if you get stuck or need guidance'
];
return response;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`Error executing workflow_helper: ${errorMessage}`);
return `Error: ${errorMessage}`;
}
}
/**
* Extract noteIds from data string
*/
private extractNoteIds(data: string): string[] {
// Look for patterns like noteId: "abc123" or "abc123def456"
const idPattern = /(?:noteId[:\s]*["']?|["'])([a-zA-Z0-9]{8,})['"]/g;
const matches: string[] = [];
let match;
while ((match = idPattern.exec(data)) !== null) {
if (match[1] && !matches.includes(match[1])) {
matches.push(match[1]);
}
}
return matches;
}
/**
* Generate specific examples for tool usage
*/
private generateExample(tool: string, parameters: Record<string, any>, noteIds: string[]): string {
const sampleNoteId = noteIds[0] || 'abc123def456';
switch (tool) {
case 'read_note':
return `{ "noteId": "${sampleNoteId}", "includeAttributes": true }`;
case 'note_update':
return `{ "noteId": "${sampleNoteId}", "content": "Updated content here" }`;
case 'attribute_manager':
return `{ "noteId": "${sampleNoteId}", "action": "add", "attributeType": "label", "attributeName": "important" }`;
case 'search_notes':
return `{ "query": "broader search terms related to your topic" }`;
case 'keyword_search_notes':
return `{ "query": "specific keywords OR alternative terms" }`;
case 'note_creation':
return `{ "title": "New Note Title", "content": "Note content here" }`;
default:
return `Use ${tool} with appropriate parameters`;
}
}
}