claude-flow-novice
Version:
Claude Flow Novice - Advanced orchestration platform for multi-agent AI workflows with CFN Loop architecture Includes Local RuVector Accelerator and all CFN skills for complete functionality.
664 lines (663 loc) • 27.9 kB
JavaScript
/**
* Anthropic API Client
*
* Handles communication with Claude API (Anthropic or z.ai provider).
* Supports streaming responses and tool execution.
*/ import Anthropic from '@anthropic-ai/sdk';
import fs from 'fs/promises';
import fsSync from 'fs';
import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import http from 'http';
import https from 'https';
import { executeTool } from './tool-executor.js';
const execAsync = promisify(exec);
// File-based debug logging (for background agents)
const AGENT_ID = process.env.AGENT_ID || 'unknown';
const API_LOG_FILE = `/tmp/cfn-api-${AGENT_ID}.log`;
function apiDebugLog(message, data) {
const timestamp = new Date().toISOString();
const logEntry = data ? `${timestamp} [${AGENT_ID}] ${message} ${JSON.stringify(data)}\n` : `${timestamp} [${AGENT_ID}] ${message}\n`;
try {
fsSync.appendFileSync(API_LOG_FILE, logEntry);
} catch (err) {
// Ignore logging errors
}
}
// HTTP Agent configuration with connection pooling limits
// Prevents memory leaks from unclosed connections in subagent spawning
const httpAgent = new http.Agent({
keepAlive: true,
maxSockets: 10,
maxFreeSockets: 5,
timeout: 120000,
keepAliveMsecs: 1000
});
const httpsAgent = new https.Agent({
keepAlive: true,
maxSockets: 10,
maxFreeSockets: 5,
timeout: 120000,
keepAliveMsecs: 1000
});
// Client singleton with reference counting for proper lifecycle management
// Fixes memory leak from creating new Anthropic instances per API call
let clientInstance = null;
let clientRefCount = 0;
/**
* Get API configuration from environment and config files
*
* Provider resolution order:
* 1. CLAUDE_API_PROVIDER env var (legacy)
* 2. PROVIDER env var (set by agent-spawner from --provider flag)
* 3. Config file (.claude/config/api-provider.json)
* 4. Default to Z.ai (cost-effective fallback per project requirements)
*
* BUG FIX: Previously only checked CLAUDE_API_PROVIDER, ignoring --provider flag
* which sets PROVIDER env var via agent-spawner.ts
*/ export async function getAPIConfig() {
// Check environment variables - support both CLAUDE_API_PROVIDER (legacy) and PROVIDER (from CLI --provider flag)
const envProvider = process.env.CLAUDE_API_PROVIDER || process.env.PROVIDER;
// Debug logging for provider routing (helps diagnose auth errors)
apiDebugLog('getAPIConfig: Provider detection', {
CLAUDE_API_PROVIDER: process.env.CLAUDE_API_PROVIDER,
PROVIDER: process.env.PROVIDER,
resolved: envProvider
});
console.error(`[provider-routing] CLAUDE_API_PROVIDER=${process.env.CLAUDE_API_PROVIDER || 'unset'}, PROVIDER=${process.env.PROVIDER || 'unset'}, resolved=${envProvider || 'none'}`);
if (envProvider === 'zai' || envProvider === 'z.ai') {
console.error('[provider-routing] Using Z.ai provider');
return {
provider: 'zai',
apiKey: process.env.ZAI_API_KEY || process.env.ANTHROPIC_API_KEY,
baseURL: process.env.ZAI_BASE_URL || 'https://api.z.ai/api/anthropic'
};
}
if (envProvider === 'kimi') {
console.error('[provider-routing] Using Kimi provider');
return {
provider: 'kimi',
apiKey: process.env.KIMI_API_KEY,
baseURL: process.env.KIMI_BASE_URL || 'https://api.moonshot.cn/v1'
};
}
if (envProvider === 'openrouter') {
console.error('[provider-routing] Using OpenRouter provider');
return {
provider: 'openrouter',
apiKey: process.env.OPENROUTER_API_KEY,
baseURL: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1'
};
}
if (envProvider === 'anthropic') {
console.error('[provider-routing] Using Anthropic provider (explicit)');
return {
provider: 'anthropic',
apiKey: process.env.ANTHROPIC_API_KEY
};
}
// Check config file
try {
const configPath = path.join('.claude', 'config', 'api-provider.json');
const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
if (config.provider === 'zai' || config.provider === 'z.ai') {
console.error('[provider-routing] Using Z.ai provider (from config file)');
return {
provider: 'zai',
apiKey: config.apiKey || process.env.ZAI_API_KEY || process.env.ANTHROPIC_API_KEY,
baseURL: config.baseURL || process.env.ZAI_BASE_URL || 'https://api.z.ai/api/anthropic'
};
}
if (config.provider === 'kimi') {
console.error('[provider-routing] Using Kimi provider (from config file)');
return {
provider: 'kimi',
apiKey: config.apiKey || process.env.KIMI_API_KEY,
baseURL: config.baseURL || process.env.KIMI_BASE_URL || 'https://api.moonshot.cn/v1'
};
}
if (config.provider === 'openrouter') {
console.error('[provider-routing] Using OpenRouter provider (from config file)');
return {
provider: 'openrouter',
apiKey: config.apiKey || process.env.OPENROUTER_API_KEY,
baseURL: config.baseURL || process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1'
};
}
if (config.provider === 'anthropic') {
console.error('[provider-routing] Using Anthropic provider (from config file)');
return {
provider: 'anthropic',
apiKey: config.apiKey || process.env.ANTHROPIC_API_KEY
};
}
} catch {
// Config file doesn't exist, use defaults
}
// Default to Z.ai (cost-effective fallback per project requirements)
// BUG FIX: Previously defaulted to Anthropic which caused auth errors when no provider specified
console.error('[provider-routing] Using Z.ai provider (default fallback)');
return {
provider: 'zai',
apiKey: process.env.ZAI_API_KEY || process.env.ANTHROPIC_API_KEY,
baseURL: process.env.ZAI_BASE_URL || 'https://api.z.ai/api/anthropic'
};
}
/**
* Validate provider configuration before creating client
* Provides clear error messages for missing credentials
*/ export function validateProviderConfig(config) {
if (!config.apiKey) {
const envVarMap = {
'zai': 'ZAI_API_KEY (or ANTHROPIC_API_KEY)',
'anthropic': 'ANTHROPIC_API_KEY',
'kimi': 'KIMI_API_KEY',
'openrouter': 'OPENROUTER_API_KEY'
};
const requiredVar = envVarMap[config.provider] || `${config.provider.toUpperCase()}_API_KEY`;
throw new Error(`[provider-validation] API key not found for provider '${config.provider}'. ` + `Set the ${requiredVar} environment variable.\n` + `Tip: If using --provider flag, ensure the corresponding API key is exported.`);
}
// Provider-specific validation
if (config.provider === 'kimi' || config.provider === 'openrouter') {
console.error(`[provider-validation] WARNING: Provider '${config.provider}' uses OpenAI-compatible API format.`);
console.error(`[provider-validation] The current implementation uses Anthropic SDK which may not be compatible.`);
console.error(`[provider-validation] Consider using provider 'zai' or 'anthropic' for now.`);
}
}
/**
* Create Anthropic client with appropriate configuration
*
* Uses singleton pattern with reference counting to prevent memory leaks
* from unclosed HTTP connections. Applies to all providers:
* - anthropic: Direct Anthropic API
* - zai: Z.ai proxy (all GLM models: 4.5, 4.6, etc.)
* - kimi: Kimi API (future)
* - openrouter: OpenRouter API (future)
*
* Memory leak fix: Reuses client instance with custom HTTP agents
* that enforce connection limits (maxSockets: 10, maxFreeSockets: 5)
*/ export async function createClient() {
// Return existing instance if available
if (clientInstance) {
clientRefCount++;
apiDebugLog('Reusing existing client instance', {
refCount: clientRefCount
});
return clientInstance;
}
const config = await getAPIConfig();
// Validate configuration before attempting API call
validateProviderConfig(config);
const clientOptions = {
apiKey: config.apiKey,
timeout: 120000,
maxRetries: 2,
httpAgent: httpAgent,
httpsAgent: httpsAgent
};
// Z.ai uses Anthropic-compatible API format with custom base URL
if ((config.provider === 'zai' || config.provider === 'anthropic') && config.baseURL) {
clientOptions.baseURL = config.baseURL;
}
console.error(`[anthropic-client] Creating new client instance for provider: ${config.provider}`);
apiDebugLog('Creating new client instance', {
provider: config.provider,
baseURL: config.baseURL
});
clientInstance = new Anthropic(clientOptions);
clientRefCount = 1;
return clientInstance;
}
/**
* Dispose Anthropic client and clean up HTTP connections
*
* Uses reference counting to ensure client is only destroyed when
* all operations have completed. Prevents premature disposal during
* concurrent API calls.
*
* Should be called in finally blocks of executeAgentAPI and similar
* long-running operations.
*/ export function disposeClient() {
clientRefCount--;
apiDebugLog('Disposing client reference', {
refCount: clientRefCount
});
if (clientRefCount <= 0 && clientInstance) {
// Force cleanup of internal HTTP client/agent
// @ts-ignore - accessing internal property for cleanup
if (clientInstance.httpClient?.destroy) {
clientInstance.httpClient.destroy();
}
apiDebugLog('Client instance destroyed');
clientInstance = null;
clientRefCount = 0;
}
}
/**
* Map agent model name to API model ID (provider-specific)
*/ export function mapModelName(agentModel, provider = 'anthropic') {
// Z.ai uses GLM models - try glm-4.6 first for all models
if (provider === 'zai') {
const zaiModelMap = {
haiku: 'glm-4.6',
sonnet: 'glm-4.6',
opus: 'glm-4.6'
};
return zaiModelMap[agentModel] || 'glm-4.6';
}
// Anthropic uses Claude models
const modelMap = {
haiku: 'claude-3-5-haiku-20241022',
sonnet: 'claude-3-5-sonnet-20241022',
opus: 'claude-3-opus-20240229'
};
return modelMap[agentModel] || modelMap.haiku;
}
/**
* Get fallback model for Z.ai (glm-4.6 -> glm-4.5-air)
*/ function getFallbackModel(model) {
if (model === 'glm-4.6') {
return 'glm-4.5-air';
}
return null;
}
/**
* Send message to Claude API with streaming support and automatic fallback
*/ export async function sendMessage(options, onChunk) {
const client = await createClient();
const config = await getAPIConfig();
// Primary model (glm-4.6 for Z.ai, Claude for Anthropic)
let model = mapModelName(options.model, config.provider);
const maxTokens = options.maxTokens || 16000; // Sprint 6: 16K hard limit for GLM-4.6 (agents target 10K for buffer)
const temperature = options.temperature ?? 1.0;
// Streaming supported for both providers; retry without streaming if a provider rejects it
let enableStreaming = !!options.stream;
console.log(`[anthropic-client] Provider: ${config.provider}`);
console.log(`[anthropic-client] Model: ${model}`);
console.log(`[anthropic-client] Max tokens: ${maxTokens}`);
console.log(`[anthropic-client] Stream: ${enableStreaming ? 'enabled' : 'disabled'}`);
console.log('');
// Sprint 4: Use messages array if provided (conversation forking)
const messages = options.messages ? options.messages.map((m)=>({
role: m.role,
content: m.content
})) : [
{
role: 'user',
content: options.prompt
}
];
// Retry logic: Try primary model (glm-4.6), fall back to glm-4.5 on error
let lastError = null;
let attempts = 0;
const maxAttempts = 2; // Primary + fallback
while(attempts < maxAttempts){
const currentModel = attempts === 0 ? model : getFallbackModel(model);
if (!currentModel) {
// No fallback available, throw last error
throw lastError || new Error('No model available');
}
attempts++;
if (attempts > 1) {
console.log(`[anthropic-client] Retrying with fallback model: ${currentModel}`);
}
const requestParams = {
model: currentModel,
max_tokens: maxTokens,
temperature,
messages
};
if (options.systemPrompt) {
requestParams.system = options.systemPrompt;
}
if (options.tools && options.tools.length > 0) {
requestParams.tools = options.tools;
}
try {
// Streaming response (preferred)
if (enableStreaming) {
let fullContent = '';
let inputTokens = 0;
let outputTokens = 0;
let stopReason = 'end_turn';
let stream = null;
try {
console.log('[anthropic-client] Creating streaming request...');
stream = await client.messages.create({
...requestParams,
stream: true
});
console.log('[anthropic-client] Stream created, processing events...');
for await (const event of stream){
console.log('[anthropic-client] Event type:', event.type);
if (event.type === 'message_start') {
// @ts-ignore - usage exists on message_start
inputTokens = event.message.usage?.input_tokens || 0;
} else if (event.type === 'content_block_delta') {
// @ts-ignore - text exists on delta
const text = event.delta?.text || '';
fullContent += text;
if (onChunk) {
onChunk(text);
}
} else if (event.type === 'message_delta') {
// @ts-ignore - usage exists on message_delta
outputTokens = event.usage?.output_tokens || 0;
// @ts-ignore - stop_reason exists on delta
stopReason = event.delta?.stop_reason || 'end_turn';
}
}
return {
content: fullContent,
usage: {
inputTokens,
outputTokens
},
stopReason
};
} finally{
// Explicit stream cleanup to prevent memory leaks
if (stream?.controller?.abort) {
try {
stream.controller.abort();
} catch (err) {
// Ignore abort errors (stream may already be closed)
}
}
}
}
// Non-streaming response
const response = await client.messages.create(requestParams);
const content = response.content.filter((block)=>block.type === 'text').map((block)=>block.text).join('\n') || '';
return {
content,
usage: {
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens
},
stopReason: response.stop_reason || 'end_turn'
};
} catch (error) {
// If streaming fails on Z.ai, retry once without streaming before falling back to model fallback logic
if (enableStreaming && config.provider === 'zai') {
console.warn('[anthropic-client] Streaming failed on z.ai, retrying without streaming:', error);
enableStreaming = false;
attempts--; // do not consume a model attempt
continue;
}
lastError = error instanceof Error ? error : new Error(String(error));
console.error(`[anthropic-client] Error with model ${currentModel}:`, lastError.message);
// If this was the last attempt, throw the error
if (attempts >= maxAttempts) {
throw lastError;
}
// Continue to next attempt with fallback model
console.log('[anthropic-client] Will retry with fallback model...');
}
}
// Should never reach here
throw lastError || new Error('All retry attempts failed');
}
/**
* Execute agent with tool support (agentic loop)
*
* Handles:
* 1. Send message with tools
* 2. Get response
* 3. If tool_use blocks, execute tools and send results back
* 4. Repeat until final text response
*/ async function executeWithTools(options, onChunk) {
const client = await createClient();
const config = await getAPIConfig();
const model = mapModelName(options.model, config.provider);
const maxTokens = options.maxTokens || 16000;
const temperature = options.temperature ?? 1.0;
// Build initial messages array
const messages = options.messages ? options.messages.map((m)=>({
role: m.role,
content: m.content
})) : [
{
role: 'user',
content: options.prompt
}
];
let totalInputTokens = 0;
let totalOutputTokens = 0;
let fullTextContent = '';
const MAX_ITERATIONS = 20; // Prevent infinite loops (increased for exploration phase)
let iteration = 0;
while(iteration < MAX_ITERATIONS){
iteration++;
console.log(`[executeWithTools] Iteration ${iteration}`);
const requestParams = {
model,
max_tokens: maxTokens,
temperature,
messages
};
if (options.systemPrompt) {
requestParams.system = options.systemPrompt;
}
if (options.tools && options.tools.length > 0) {
requestParams.tools = options.tools;
}
// Make API request (non-streaming for now to handle tool_use)
const response = await client.messages.create(requestParams);
apiDebugLog('executeWithTools: API response received', {
iteration,
contentBlockCount: response.content.length,
blockTypes: response.content.map((b)=>b.type),
stopReason: response.stop_reason
});
totalInputTokens += response.usage.input_tokens;
totalOutputTokens += response.usage.output_tokens;
// Extract content blocks
const textBlocks = response.content.filter((block)=>block.type === 'text');
const toolUseBlocks = response.content.filter((block)=>block.type === 'tool_use');
apiDebugLog('executeWithTools: Blocks extracted', {
textBlockCount: textBlocks.length,
toolUseBlockCount: toolUseBlocks.length,
toolNames: toolUseBlocks.map((b)=>b.name)
});
// Stream text output
for (const block of textBlocks){
if (block.type === 'text') {
const text = block.text;
fullTextContent += text;
if (onChunk) {
onChunk(text);
}
}
}
// If no tool uses, we're done
if (toolUseBlocks.length === 0) {
console.log(`[executeWithTools] No tool uses, completing`);
return {
content: fullTextContent,
usage: {
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens
},
stopReason: response.stop_reason || 'end_turn'
};
}
// Execute tools
console.log(`[executeWithTools] Executing ${toolUseBlocks.length} tool(s)`);
const toolResults = [];
for (const toolUseBlock of toolUseBlocks){
if (toolUseBlock.type !== 'tool_use') continue;
const toolUse = {
type: 'tool_use',
id: toolUseBlock.id,
name: toolUseBlock.name,
input: toolUseBlock.input
};
console.log(`[executeWithTools] Tool: ${toolUse.name}`);
const result = await executeTool(toolUse);
toolResults.push(result);
// Stream tool result
if (onChunk) {
onChunk(`\n[Tool: ${toolUse.name}] ${result.content.substring(0, 100)}${result.content.length > 100 ? '...' : ''}\n`);
}
}
// Add assistant message with tool_use
messages.push({
role: 'assistant',
content: response.content
});
// Add tool results as user message
messages.push({
role: 'user',
content: toolResults
});
// Continue to next iteration
}
// Reached max iterations
console.warn(`[executeWithTools] Reached max iterations (${MAX_ITERATIONS})`);
return {
content: fullTextContent,
usage: {
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens
},
stopReason: 'max_tokens'
};
}
/**
* Execute agent via API with full lifecycle
*/ export async function executeAgentAPI(agentType, agentId, model, prompt, systemPrompt, messages, maxTokens, tools// Tool definitions for agent capabilities
) {
// Start heartbeat monitoring (declare at function scope for error handling)
let heartbeatInterval = null;
const taskId = process.env.TASK_ID;
// Bug #6 Fix: Read Redis connection parameters from process.env and interpolate in TypeScript
// FIX: Default to 'localhost' for CLI mode (host execution), not 'cfn-redis' (Docker)
const redisHost = process.env.CFN_REDIS_HOST || 'localhost';
const redisPort = process.env.CFN_REDIS_PORT || '6379';
// Track memory usage for leak detection
const initialMem = process.memoryUsage();
apiDebugLog('Memory before API call', {
heapUsed: (initialMem.heapUsed / 1024 / 1024).toFixed(2) + 'MB',
external: (initialMem.external / 1024 / 1024).toFixed(2) + 'MB'
});
try {
apiDebugLog('executeAgentAPI: ENTRY', {
agentType,
agentId,
hasTools: !!tools,
toolsLength: tools?.length || 0,
toolNames: tools?.map((t)=>t.name) || []
});
console.log(`[anthropic-client] Executing agent: ${agentType}`);
console.log(`[anthropic-client] Agent ID: ${agentId}`);
console.error(`[TOOL DEBUG executeAgentAPI] tools parameter: ${tools ? `Array[${tools.length}]` : 'undefined'}`);
console.error(`[TOOL DEBUG executeAgentAPI] tools names: ${tools?.map((t)=>t.name).join(', ') || 'NONE'}`);
if (messages && messages.length > 1) {
console.log(`[anthropic-client] Continuing conversation (${messages.length} messages)`);
}
console.log('');
if (taskId) {
heartbeatInterval = setInterval(async ()=>{
try {
await execAsync(`redis-cli -h ${redisHost} -p ${redisPort} hset "swarm:${taskId}:agent:${agentId}" heartbeat "${Date.now()}" status "working"`);
} catch (err) {
console.error('[heartbeat] Error sending heartbeat:', err);
}
}, 30000); // Every 30 seconds
console.log(`[heartbeat] Monitoring started for agent ${agentId} (30s interval)`);
}
let fullOutput = '';
// If tools provided, use agentic loop with tool execution
// Otherwise use simple streaming
let response;
if (tools && tools.length > 0) {
apiDebugLog('executeAgentAPI: Using tool execution path', {
toolCount: tools.length,
toolNames: tools.map((t)=>t.name)
});
console.log(`[anthropic-client] Tools enabled: ${tools.map((t)=>t.name).join(', ')}`);
response = await executeWithTools({
model,
prompt,
systemPrompt,
messages,
maxTokens,
tools
}, (chunk)=>{
process.stdout.write(chunk);
fullOutput += chunk;
});
} else {
response = await sendMessage({
model,
prompt,
systemPrompt,
stream: true,
messages,
maxTokens
}, (chunk)=>{
process.stdout.write(chunk);
fullOutput += chunk;
});
}
console.log('\n');
console.log('=== Agent Execution Complete ===');
console.log(`Input tokens: ${response.usage.inputTokens}`);
console.log(`Output tokens: ${response.usage.outputTokens}`);
console.log(`Stop reason: ${response.stopReason}`);
// Stop heartbeat and send final status
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
if (taskId) {
await execAsync(`redis-cli -h ${redisHost} -p ${redisPort} hset "swarm:${taskId}:agent:${agentId}" heartbeat "${Date.now()}" status "complete"`);
console.log(`[heartbeat] Monitoring stopped - agent ${agentId} complete`);
}
}
return {
success: true,
output: response.content,
usage: response.usage
};
} catch (error) {
console.error('[anthropic-client] Error:', error);
// Stop heartbeat and send error status
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
if (taskId) {
try {
await execAsync(`redis-cli -h ${redisHost} -p ${redisPort} hset "swarm:${taskId}:agent:${agentId}" heartbeat "${Date.now()}" status "error"`);
console.log(`[heartbeat] Monitoring stopped - agent ${agentId} error`);
} catch (err) {
// Ignore heartbeat errors during error handling
}
}
}
return {
success: false,
output: '',
usage: {
inputTokens: 0,
outputTokens: 0
},
error: error instanceof Error ? error.message : String(error)
};
} finally{
// Dispose client to prevent memory leaks
// Reference counting ensures client is only destroyed when all operations complete
disposeClient();
// Track memory after cleanup
const finalMem = process.memoryUsage();
const heapDelta = (finalMem.heapUsed - initialMem.heapUsed) / 1024 / 1024;
apiDebugLog('Memory after API call', {
heapUsed: (finalMem.heapUsed / 1024 / 1024).toFixed(2) + 'MB',
delta: heapDelta.toFixed(2) + 'MB'
});
// Warn if significant memory growth (>50MB) detected
if (heapDelta > 50) {
console.warn(`[memory-leak-warning] Heap grew by ${heapDelta.toFixed(2)}MB during agent execution`);
}
}
}
//# sourceMappingURL=anthropic-client.js.map