UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

401 lines 17.3 kB
/** * Shared agent-execution core. * * Both the agent_execute MCP tool and the workflow runtime (G3) need * to dispatch a prompt to an agent's configured Anthropic model. This * module factors that path out so it's testable and reusable, and * keeps the wire from agent_spawn → ProviderManager (real) in one * place rather than duplicated. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { getProjectCwd } from './types.js'; const STORAGE_DIR = '.claude-flow'; const AGENT_DIR = 'agents'; const AGENT_FILE = 'store.json'; function getAgentDir() { return join(getProjectCwd(), STORAGE_DIR, AGENT_DIR); } function getAgentPath() { return join(getAgentDir(), AGENT_FILE); } function ensureAgentDir() { const dir = getAgentDir(); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } function loadAgentStore() { try { if (existsSync(getAgentPath())) return JSON.parse(readFileSync(getAgentPath(), 'utf-8')); } catch { /* fall through */ } return { agents: {}, version: '3.0.0' }; } function saveAgentStore(store) { ensureAgentDir(); writeFileSync(getAgentPath(), JSON.stringify(store, null, 2), 'utf-8'); } // #1906 — these were stuck on Claude-3.x ids that the Anthropic API now // 404s. Current model ids (Claude 4.x family): // Opus 4.7 → claude-opus-4-7 // Sonnet 4.6 → claude-sonnet-4-6 // Haiku 4.5 → claude-haiku-4-5-20251001 // `inherit` and the various defaults below all map to Sonnet 4.6. export const DEFAULT_ANTHROPIC_MODEL = 'claude-sonnet-4-6'; const MODEL_MAP = { haiku: 'claude-haiku-4-5-20251001', sonnet: 'claude-sonnet-4-6', opus: 'claude-opus-4-7', inherit: DEFAULT_ANTHROPIC_MODEL, }; /** * Generic Anthropic Messages API call. No agent registry coupling — used * by agent_execute (with the agent's configured model) and by the WASM * agent runtime (G4) when the bundled WASM only echoes input. * * #1725 — falls back to Ollama Cloud (Tier-2, OpenAI-compat) when * ANTHROPIC_API_KEY is unset and OLLAMA_API_KEY is present, or when * RUFLO_PROVIDER=ollama is explicitly set. Response shape is normalized * to the Anthropic-flavored AnthropicCallResult so existing callers * don't need to know which provider answered. */ export async function callAnthropicMessages(input) { const explicitProvider = (process.env.RUFLO_PROVIDER || '').toLowerCase(); const ollamaKey = process.env.OLLAMA_API_KEY; const anthropicKey = process.env.ANTHROPIC_API_KEY; // #2042 — OpenRouter is an OpenAI-compat endpoint that fronts dozens of // providers. Reporter (@ummcke00) had `providers.openrouter.apiKey` in // their config.yaml but agent_execute hardcoded Anthropic. Detect via // explicit RUFLO_PROVIDER=openrouter OR presence of OPENROUTER_API_KEY // when no Anthropic key is available (same precedence as the Ollama // branch above). const openrouterKey = process.env.OPENROUTER_API_KEY; const useOpenRouter = explicitProvider === 'openrouter' || (!anthropicKey && !!openrouterKey); const useOllama = explicitProvider === 'ollama' || (!anthropicKey && !!ollamaKey && !openrouterKey); if (useOpenRouter && openrouterKey) { return callOpenAICompat({ ...input, apiKey: openrouterKey, baseUrl: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api', providerLabel: 'openrouter', defaultModel: process.env.OPENROUTER_DEFAULT_MODEL || 'anthropic/claude-3.5-sonnet', }); } if (useOllama && ollamaKey) { return callOllamaCompat({ ...input, apiKey: ollamaKey }); } if (!anthropicKey) { return { success: false, error: 'No LLM provider configured. Set ANTHROPIC_API_KEY (Tier-3), OPENROUTER_API_KEY (#2042), or OLLAMA_API_KEY (Tier-2 — #1725).', }; } const model = input.model || DEFAULT_ANTHROPIC_MODEL; const startedAt = Date.now(); try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), input.timeoutMs || 60000); const res = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', }, body: JSON.stringify({ model, max_tokens: input.maxTokens || 1024, temperature: typeof input.temperature === 'number' ? input.temperature : 0.7, // #8 prompt caching (hermes-agent pattern): mark the (often large, // stable) system prompt as an ephemeral cache breakpoint so repeated // agent_execute calls with the same system prompt hit Anthropic's // prompt cache (~90% discount on cached input tokens, 5-min TTL). ...(input.systemPrompt ? { system: [{ type: 'text', text: input.systemPrompt, cache_control: { type: 'ephemeral' } }] } : {}), messages: [{ role: 'user', content: input.prompt }], }), signal: controller.signal, }); clearTimeout(timer); if (!res.ok) { const errText = await res.text().catch(() => '<unreadable error body>'); return { success: false, model, error: `Anthropic API error ${res.status}: ${errText.slice(0, 400)}` }; } const data = await res.json(); const textOut = data.content .filter(c => c.type === 'text' && typeof c.text === 'string') .map(c => c.text) .join(''); return { success: true, model: data.model, messageId: data.id, stopReason: data.stop_reason, output: textOut, usage: { inputTokens: data.usage.input_tokens, outputTokens: data.usage.output_tokens, totalTokens: data.usage.input_tokens + data.usage.output_tokens, }, durationMs: Date.now() - startedAt, }; } catch (err) { return { success: false, model, error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - startedAt, }; } } /** * Ollama Cloud / OpenAI-compat provider — Tier-2 routing per ADR-026 + #1725. * * Endpoint: https://ollama.com/v1/chat/completions * Auth: Authorization: Bearer <OLLAMA_API_KEY> * * Translates the Anthropic-flavored input shape onto OpenAI chat-completions * and translates the response back so callers never see provider-specific * fields. Logical model names are mapped to Ollama Cloud defaults: * - 'haiku' / 'sonnet' → 'gpt-oss:120b-cloud' (sensible single default) * - 'opus' → 'gpt-oss:120b-cloud' (no opus tier on Ollama) * - explicit 'ollama:<model>' or bare provider-native name → passed through */ async function callOllamaCompat(input) { const model = resolveOllamaModel(input.model); const startedAt = Date.now(); // OLLAMA_BASE_URL lets users point at local/self-hosted endpoints // (e.g. http://ruvultra:11434, http://localhost:11434) instead of // Ollama Cloud. Default is the public cloud endpoint. const base = (process.env.OLLAMA_BASE_URL || 'https://ollama.com').replace(/\/+$/, ''); const url = `${base}/v1/chat/completions`; // Self-hosted endpoints typically don't need an Authorization header // (the daemon binds to 11434 with no auth by default), but Ollama Cloud // does. Send the bearer when the key is non-empty AND looks cloud-shaped. const sendAuth = input.apiKey && input.apiKey !== 'local'; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), input.timeoutMs || 60000); const res = await fetch(url, { method: 'POST', headers: { ...(sendAuth ? { Authorization: `Bearer ${input.apiKey}` } : {}), 'content-type': 'application/json', }, body: JSON.stringify({ model, max_tokens: input.maxTokens || 1024, temperature: typeof input.temperature === 'number' ? input.temperature : 0.7, messages: [ ...(input.systemPrompt ? [{ role: 'system', content: input.systemPrompt }] : []), { role: 'user', content: input.prompt }, ], }), signal: controller.signal, }); clearTimeout(timer); if (!res.ok) { const errText = await res.text().catch(() => '<unreadable error body>'); return { success: false, model, error: `Ollama API error ${res.status} at ${url}: ${errText.slice(0, 400)}` }; } const data = (await res.json()); const textOut = data.choices?.[0]?.message?.content ?? ''; const usage = data.usage ?? {}; return { success: true, model: data.model ?? model, messageId: data.id ?? `ollama-${Date.now()}`, stopReason: data.choices?.[0]?.finish_reason ?? 'end_turn', output: textOut, usage: { inputTokens: usage.prompt_tokens ?? 0, outputTokens: usage.completion_tokens ?? 0, totalTokens: usage.total_tokens ?? 0, }, durationMs: Date.now() - startedAt, }; } catch (err) { return { success: false, model, error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - startedAt, }; } } /** * Generic OpenAI-compat caller for OpenRouter and other OpenAI-shaped * endpoints. #2042 — reporter (@ummcke00) configured OpenRouter via * config.yaml but agent_execute hardcoded the Anthropic fetch. This is * the same shape as `callOllamaCompat` but routes to a configurable * baseUrl + sends an OpenRouter-friendly default model when none is * specified. Logical model names (haiku/sonnet/opus) pass through — * OpenRouter accepts vendor-prefixed names like `anthropic/claude-3.5-sonnet`. */ async function callOpenAICompat(input) { const model = resolveOpenAICompatModel(input.model, input.defaultModel); const startedAt = Date.now(); const base = input.baseUrl.replace(/\/+$/, ''); const url = `${base}/v1/chat/completions`; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), input.timeoutMs || 60000); const messages = []; if (input.systemPrompt) messages.push({ role: 'system', content: input.systemPrompt }); messages.push({ role: 'user', content: input.prompt }); const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${input.apiKey}`, 'content-type': 'application/json', // OpenRouter convention: identify the integrating app for analytics // and rate-limit tiering. Harmless on other OpenAI-compat backends. 'HTTP-Referer': 'https://github.com/ruvnet/ruflo', 'X-Title': 'Ruflo', }, body: JSON.stringify({ model, max_tokens: input.maxTokens || 1024, temperature: typeof input.temperature === 'number' ? input.temperature : 0.7, messages, }), signal: controller.signal, }); clearTimeout(timer); if (!res.ok) { const errText = await res.text().catch(() => '<unreadable error body>'); return { success: false, model, error: `${input.providerLabel} API error ${res.status}: ${errText.slice(0, 400)}` }; } const data = await res.json(); const textOut = data.choices?.[0]?.message?.content ?? ''; const usage = data.usage ?? {}; return { success: true, model: data.model || model, messageId: data.id, stopReason: data.choices?.[0]?.finish_reason ?? 'end_turn', output: textOut, usage: { inputTokens: usage.prompt_tokens ?? 0, outputTokens: usage.completion_tokens ?? 0, totalTokens: usage.total_tokens ?? 0, }, durationMs: Date.now() - startedAt, }; } catch (err) { return { success: false, model, error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - startedAt, }; } } function resolveOpenAICompatModel(input, fallback) { if (!input) return fallback; // Logical Claude names → OpenRouter Anthropic-vendored names if (input === 'haiku') return 'anthropic/claude-3.5-haiku'; if (input === 'sonnet' || input === 'inherit') return 'anthropic/claude-3.5-sonnet'; if (input === 'opus') return 'anthropic/claude-3-opus'; return input; } function resolveOllamaModel(input) { const DEFAULT = 'gpt-oss:120b-cloud'; if (!input) return DEFAULT; // Logical → cloud default if (input === 'haiku' || input === 'sonnet' || input === 'opus' || input === 'inherit') { return DEFAULT; } // Explicit provider prefix if (input.startsWith('ollama:')) return input.slice('ollama:'.length); // Bare name with cloud suffix (e.g. 'llama3:70b-cloud') passes through return input; } /** * Resolve a model identifier to an Anthropic model ID. Accepts: * - logical names: 'haiku', 'sonnet', 'opus', 'inherit' * - prefixed: 'anthropic:claude-sonnet-4-6' * - direct: 'claude-sonnet-4-6' */ export function resolveAnthropicModel(input) { if (!input) return DEFAULT_ANTHROPIC_MODEL; if (input in MODEL_MAP) return MODEL_MAP[input]; if (input.startsWith('anthropic:')) return input.slice('anthropic:'.length); return input; } export async function executeAgentTask(input) { const store = loadAgentStore(); const agent = store.agents[input.agentId]; if (!agent) return { success: false, agentId: input.agentId, error: 'Agent not found' }; if (agent.status === 'terminated') return { success: false, agentId: input.agentId, error: 'Agent has been terminated' }; const anthropicModel = MODEL_MAP[agent.model || 'sonnet'] || DEFAULT_ANTHROPIC_MODEL; const systemPrompt = input.systemPrompt || `You are a ${agent.agentType} agent operating as part of a Ruflo swarm. ` + `Agent ID: ${input.agentId}. Domain: ${agent.domain ?? 'general'}. ` + `Respond directly and stay focused on the task. If you need information you don't have, state that explicitly.`; agent.status = 'busy'; agent.taskCount = (agent.taskCount || 0) + 1; saveAgentStore(store); const startedAt = Date.now(); // #2042 — delegate to callAnthropicMessages so the v3 provider router // (Anthropic / Ollama / OpenRouter) governs which backend is hit. The // previous inline `fetch('https://api.anthropic.com/...')` bypassed // the router entirely and forced an ANTHROPIC_API_KEY error for every // non-Anthropic deployment. Reporter (@ummcke00) had OpenRouter // configured but the bypass made the agent unreachable. const result = await callAnthropicMessages({ model: anthropicModel, prompt: input.prompt, systemPrompt, maxTokens: input.maxTokens, temperature: input.temperature, timeoutMs: input.timeoutMs, }); agent.status = 'idle'; if (result.success) { const out = { success: true, agentId: input.agentId, messageId: result.messageId, model: result.model, stopReason: result.stopReason, output: result.output, usage: result.usage, durationMs: result.durationMs ?? Date.now() - startedAt, }; agent.lastResult = out; saveAgentStore(store); return out; } saveAgentStore(store); // No-provider-configured error → surface the same actionable message // the router built, with a #2042-aware remediation pointer. const noProvider = (result.error || '').includes('No LLM provider configured'); return { success: false, agentId: input.agentId, model: anthropicModel, error: result.error || 'agent_execute failed', durationMs: result.durationMs ?? Date.now() - startedAt, ...(noProvider && { remediation: 'Set one of ANTHROPIC_API_KEY, OPENROUTER_API_KEY (+ optional OPENROUTER_BASE_URL), or OLLAMA_API_KEY. ' + 'Or set RUFLO_PROVIDER=openrouter|ollama to force a specific provider.', }), }; } //# sourceMappingURL=agent-execute-core.js.map