example-remote-client/src/hooks/useAgentLoop.ts
Jerome 763742ed30
Implement improved auto-reconnect logic and fix TypeScript errors
## Features
- Add proactive health check monitoring for MCP connections (30s intervals)
- Implement automatic reconnection on health check failures
- Add proper cleanup of health check intervals on disconnect

## Fixes
- Resolve OAuth type compatibility issues with MCP SDK
- Fix naming collision between InferenceProvider class and React component
- Remove unused React imports across all components (new JSX transform)
- Clean up unused type imports and variables
- Remove unused handleOAuthAuthentication function

## Technical Details
- Health checks use lightweight listTools() calls to verify connection state
- Automatic reconnection triggers when health checks fail
- Maintains existing exponential backoff retry logic
- All TypeScript compilation errors resolved

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-12 14:02:00 +01:00

418 lines
No EOL
13 KiB
TypeScript

// Agent loop hook with MCP and test tool integration
import { useCallback, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import type {
AgentLoopConfig,
AgentLoopState,
UseAgentLoopReturn,
ConversationMessage,
Conversation,
ToolResultBlock,
TestTool,
} from '@/types/conversation';
import type { ChatMessage, Tool, ToolCall, InferenceRequest } from '@/types/inference';
import { useInference } from '@/contexts/InferenceContext';
import { useMCP } from '@/contexts/MCPContext';
// Test tools that work alongside MCP tools
const testTools: TestTool[] = [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get current weather for a location',
parameters: {
type: 'object',
properties: {
location: {
type: 'string',
description: 'The city and state, e.g. San Francisco, CA',
},
},
required: ['location'],
},
},
execute: async (args) => {
// Mock weather data
const weather = {
location: args.location,
temperature: Math.floor(Math.random() * 30) + 10,
condition: ['sunny', 'cloudy', 'rainy', 'partly cloudy'][Math.floor(Math.random() * 4)],
humidity: Math.floor(Math.random() * 40) + 30,
};
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000));
return weather;
},
},
{
type: 'function',
function: {
name: 'calculate',
description: 'Perform basic arithmetic calculations',
parameters: {
type: 'object',
properties: {
expression: {
type: 'string',
description: 'Mathematical expression to evaluate (e.g., "2 + 2", "10 * 3")',
},
},
required: ['expression'],
},
},
execute: async (args) => {
try {
// Simple expression evaluator (basic safety check)
const expression = args.expression.replace(/[^0-9+\-*/().\s]/g, '');
if (expression !== args.expression) {
throw new Error('Invalid characters in expression');
}
const result = eval(expression);
return { expression: args.expression, result };
} catch (error) {
throw new Error(`Calculation error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
},
},
{
type: 'function',
function: {
name: 'get_current_time',
description: 'Get the current date and time',
parameters: {
type: 'object',
properties: {
timezone: {
type: 'string',
description: 'Timezone (e.g., "UTC", "America/New_York")',
},
},
},
},
execute: async (args) => {
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short',
};
if (args.timezone) {
options.timeZone = args.timezone;
}
return {
timestamp: new Date().toISOString(),
formatted: new Date().toLocaleString('en-US', options),
timezone: args.timezone || 'local',
};
},
},
];
const DEFAULT_CONFIG: AgentLoopConfig = {
maxIterations: 10,
systemMessage: 'You are a helpful assistant with access to various tools. Use them when needed to answer questions accurately.',
temperature: 0.7,
stopOnError: false,
};
export function useAgentLoop(config: Partial<AgentLoopConfig> = {}): UseAgentLoopReturn {
const finalConfig = { ...DEFAULT_CONFIG, ...config };
const { provider: currentProvider, isAuthenticated } = useInference();
const { getAllTools, callTool: callMCPTool, connections } = useMCP();
// Track running loops
const loopStates = useRef<Map<string, AgentLoopState>>(new Map());
// Helper: Convert conversation messages to inference format
const toInferenceMessages = useCallback((messages: ConversationMessage[]): ChatMessage[] => {
return messages.map(msg => {
// Convert content blocks back to the inference format
if (msg.content.length === 1 && msg.content[0].type === 'text') {
// Simple text message
return {
role: msg.role,
content: msg.content[0].text,
};
}
// Complex message with tool calls/results - reconstruct the format
let content = '';
const toolCalls: ToolCall[] = [];
let toolCallId: string | undefined;
for (const block of msg.content) {
if (block.type === 'text') {
content += block.text;
} else if (block.type === 'tool_use') {
toolCalls.push({
id: block.id,
type: 'function',
function: {
name: block.name,
arguments: block.input,
},
});
} else if (block.type === 'tool_result') {
toolCallId = block.tool_use_id;
// For tool result messages, content comes from the tool result
if (typeof block.content === 'string') {
content = block.content;
} else if (Array.isArray(block.content)) {
content = block.content.map(c => c.text || c.error || '').join('');
}
}
}
return {
role: msg.role,
content: content || '',
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolCallId,
};
});
}, []);
// Helper: Create conversation message from inference response
const fromInferenceResponse = useCallback((response: ChatMessage): ConversationMessage => {
const content: any[] = [];
// Add text content if present
if (typeof response.content === 'string' && response.content) {
content.push({
type: 'text',
text: response.content,
});
}
// Add tool calls if present
if (response.toolCalls) {
for (const toolCall of response.toolCalls) {
content.push({
type: 'tool_use',
id: toolCall.id,
name: toolCall.function.name,
input: toolCall.function.arguments,
});
}
}
return {
id: uuidv4(),
role: response.role,
content,
timestamp: new Date(),
toolCalls: response.toolCalls,
toolCallId: response.toolCallId,
};
}, []);
// Helper: Execute a tool call
const executeTool = useCallback(async (toolCall: ToolCall): Promise<{ result: any; error?: string }> => {
try {
// Check if it's a test tool
const testTool = testTools.find(t => t.function.name === toolCall.function.name);
if (testTool) {
const result = await testTool.execute(toolCall.function.arguments);
return { result };
}
// Check if it's an MCP tool (prefixed with server name using double underscore)
if (toolCall.function.name.includes('__')) {
// Extract server name from tool name (format: "server__tool_name")
const [serverName] = toolCall.function.name.split('__');
// Find the connection ID for this server name
const connection = connections.find(conn => conn.name === serverName);
if (connection) {
const result = await callMCPTool(connection.id, toolCall.function.name, toolCall.function.arguments);
return { result };
} else {
throw new Error(`MCP server "${serverName}" not found or not connected`);
}
}
throw new Error(`Unknown tool: ${toolCall.function.name}`);
} catch (error) {
return {
result: null,
error: error instanceof Error ? error.message : 'Tool execution failed',
};
}
}, [getAllTools, callMCPTool, connections]);
// Main agent loop execution - processes the conversation and generates responses with tool calls
const executeAgentLoop = useCallback(async (
conversation: Conversation,
onUpdate: (conversation: Conversation) => void
) => {
if (!isAuthenticated || !currentProvider) {
throw new Error('No authenticated inference provider available');
}
const conversationId = conversation.id;
// Initialize loop state
const abortController = new AbortController();
const loopState: AgentLoopState = {
isRunning: true,
conversationId,
currentStep: 'inference',
iteration: 0,
maxIterations: finalConfig.maxIterations,
abortController,
};
loopStates.current.set(conversationId, loopState);
try {
let currentConversation = { ...conversation };
for (let iteration = 0; iteration < finalConfig.maxIterations; iteration++) {
if (abortController.signal.aborted) {
break;
}
loopState.iteration = iteration;
loopState.currentStep = 'inference';
// Convert to inference format and add system message if needed
const inferenceMessages = toInferenceMessages(currentConversation.messages);
if (finalConfig.systemMessage && (inferenceMessages.length === 0 || inferenceMessages[0].role !== 'system')) {
inferenceMessages.unshift({
role: 'system',
content: finalConfig.systemMessage,
});
}
// Get available tools (test tools + MCP tools)
const allTools: Tool[] = [
...testTools.map(t => ({ type: t.type, function: t.function })),
...getAllTools(),
];
// Make inference request
const request: InferenceRequest = {
messages: inferenceMessages,
tools: allTools.length > 0 ? allTools : undefined,
temperature: finalConfig.temperature,
};
const response = await currentProvider.generateResponse(request);
if (abortController.signal.aborted) {
break;
}
// Add assistant response to conversation
const assistantMessage = fromInferenceResponse(response.message);
currentConversation = {
...currentConversation,
messages: [...currentConversation.messages, assistantMessage],
updatedAt: new Date(),
status: response.message.toolCalls ? 'calling_tools' : 'idle',
};
onUpdate(currentConversation);
// Check if we have tool calls to execute
if (!response.message.toolCalls || response.message.toolCalls.length === 0) {
// No tool calls, we're done
break;
}
// Execute tool calls
loopState.currentStep = 'tool_execution';
currentConversation.status = 'calling_tools';
onUpdate(currentConversation);
const toolResults: ToolResultBlock[] = [];
for (const toolCall of response.message.toolCalls) {
if (abortController.signal.aborted) {
break;
}
const { result, error } = await executeTool(toolCall);
const toolResult: ToolResultBlock = {
type: 'tool_result',
tool_use_id: toolCall.id,
content: error
? [{ type: 'error', error }]
: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
is_error: !!error,
};
toolResults.push(toolResult);
}
// Add tool results as separate messages
const toolResultMessages: ConversationMessage[] = toolResults.map(result => ({
id: uuidv4(),
role: 'tool' as const,
content: [result],
timestamp: new Date(),
toolCallId: result.tool_use_id,
}));
currentConversation = {
...currentConversation,
messages: [...currentConversation.messages, ...toolResultMessages],
status: 'thinking',
updatedAt: new Date(),
};
onUpdate(currentConversation);
// Continue the loop to process tool results
}
// Mark as complete
loopState.currentStep = 'complete';
loopState.isRunning = false;
if (currentConversation) {
currentConversation.status = 'idle';
onUpdate(currentConversation);
}
} catch (error) {
loopState.error = error instanceof Error ? error.message : 'Agent loop failed';
loopState.isRunning = false;
if (finalConfig.stopOnError) {
throw error;
}
} finally {
loopStates.current.delete(conversationId);
}
}, [isAuthenticated, currentProvider, finalConfig, toInferenceMessages, fromInferenceResponse, getAllTools, executeTool]);
const stopLoop = useCallback((conversationId: string) => {
const loopState = loopStates.current.get(conversationId);
if (loopState?.abortController) {
loopState.abortController.abort();
loopState.isRunning = false;
}
}, []);
const getLoopState = useCallback((conversationId: string): AgentLoopState | undefined => {
return loopStates.current.get(conversationId);
}, []);
return {
executeAgentLoop,
stopLoop,
getLoopState,
};
}