431 lines
15 KiB
TypeScript
431 lines
15 KiB
TypeScript
import 'server-only';
|
|
import { WorkflowNode, WorkflowState } from '../types';
|
|
import { substituteVariables } from '../variable-substitution';
|
|
import { resolveMCPServers, migrateMCPData } from '@/lib/mcp/resolver';
|
|
|
|
/**
|
|
* Execute Agent Node - Calls LLM with instructions and tools
|
|
* Server-side only - called from API routes
|
|
*/
|
|
export async function executeAgentNode(
|
|
node: WorkflowNode,
|
|
state: WorkflowState,
|
|
apiKeys?: { anthropic?: string; groq?: string; openai?: string; firecrawl?: string }
|
|
): Promise<any> {
|
|
const { data } = node;
|
|
|
|
try {
|
|
// Substitute variables in instructions
|
|
const originalInstructions = data.instructions || 'Process the input';
|
|
const instructions = substituteVariables(originalInstructions, state);
|
|
|
|
// Build context from previous node output
|
|
const lastOutput = state.variables?.lastOutput;
|
|
|
|
// Migrate data if using old format
|
|
const migratedData = migrateMCPData(data);
|
|
|
|
// Resolve MCP server IDs to full configurations
|
|
let mcpTools = migratedData.mcpTools || [];
|
|
if (migratedData.mcpServerIds && migratedData.mcpServerIds.length > 0) {
|
|
// Fetch MCP configurations from registry
|
|
mcpTools = await resolveMCPServers(migratedData.mcpServerIds);
|
|
}
|
|
|
|
// Validate API keys are provided
|
|
if (!apiKeys) {
|
|
throw new Error('API keys are required for server-side execution');
|
|
}
|
|
|
|
// Server-side execution only
|
|
if (process.env.MOCK_AGENT_RESPONSE) {
|
|
type MockConfig = string | Record<string, unknown>;
|
|
let mockConfig: MockConfig = process.env.MOCK_AGENT_RESPONSE;
|
|
try {
|
|
mockConfig = JSON.parse(process.env.MOCK_AGENT_RESPONSE);
|
|
} catch (e) {
|
|
// Keep raw string if parsing fails
|
|
}
|
|
|
|
let mockOutput: unknown = mockConfig;
|
|
if (mockConfig && typeof mockConfig === 'object') {
|
|
const nodeKey = node.id;
|
|
const nodeName = node.data.nodeName as string | undefined;
|
|
mockOutput = mockConfig[nodeKey] ?? (nodeName ? mockConfig[nodeName] : undefined) ?? mockConfig.default ?? mockOutput;
|
|
}
|
|
|
|
if (mockOutput !== undefined) {
|
|
const mockChatUpdates = data.includeChatHistory
|
|
? [
|
|
{ role: 'user', content: data.instructions || '' },
|
|
{ role: 'assistant', content: typeof mockOutput === 'string' ? mockOutput : JSON.stringify(mockOutput) },
|
|
]
|
|
: [];
|
|
|
|
return {
|
|
__agentValue: mockOutput,
|
|
__agentToolCalls: [],
|
|
__chatHistoryUpdates: mockChatUpdates,
|
|
__variableUpdates: { lastOutput: mockOutput },
|
|
};
|
|
}
|
|
}
|
|
|
|
// Use the already-substituted instructions from line 20
|
|
// Don't re-process or append context if variables are already substituted
|
|
const contextualPrompt = instructions;
|
|
|
|
// Prepare messages
|
|
const messages = data.includeChatHistory && state.chatHistory.length > 0
|
|
? [
|
|
...state.chatHistory,
|
|
{ role: 'user' as const, content: contextualPrompt },
|
|
]
|
|
: [{ role: 'user' as const, content: contextualPrompt }];
|
|
|
|
// Parse model string (handle models with slashes like groq/openai/gpt-oss-120b)
|
|
const modelString = data.model || 'anthropic/claude-sonnet-4-5-20250929';
|
|
let provider: string;
|
|
let modelName: string;
|
|
|
|
if (modelString.includes('/')) {
|
|
const firstSlashIndex = modelString.indexOf('/');
|
|
provider = modelString.substring(0, firstSlashIndex);
|
|
modelName = modelString.substring(firstSlashIndex + 1);
|
|
} else {
|
|
provider = 'openai';
|
|
modelName = modelString;
|
|
}
|
|
|
|
// Use native SDKs for better MCP support
|
|
let responseText = '';
|
|
interface LLMUsage {
|
|
input_tokens?: number;
|
|
output_tokens?: number;
|
|
total_tokens?: number;
|
|
prompt_tokens?: number;
|
|
completion_tokens?: number;
|
|
[key: string]: unknown;
|
|
}
|
|
let usage: LLMUsage = {
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
total_tokens: 0,
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
};
|
|
let toolCalls: any[] = [];
|
|
|
|
// Check if MCP tools are configured
|
|
// mcpTools already resolved above from mcpServerIds or mcpTools
|
|
const hasMcpTools = mcpTools.length > 0;
|
|
|
|
if (provider === 'anthropic' && apiKeys?.anthropic) {
|
|
// Use native Anthropic SDK for MCP support
|
|
const Anthropic = (await import('@anthropic-ai/sdk')).default;
|
|
const client = new Anthropic({ apiKey: apiKeys.anthropic });
|
|
|
|
if (hasMcpTools) {
|
|
// Separate Arcade from real MCP tools
|
|
const arcadeTools = mcpTools.filter((mcp: any) => mcp.name?.toLowerCase().includes('arcade'));
|
|
const realMcpTools = mcpTools.filter((mcp: any) => !mcp.name?.toLowerCase().includes('arcade'));
|
|
|
|
if (arcadeTools.length > 0) {
|
|
console.warn('⚠️ Arcade tools detected in MCP config - these will be skipped');
|
|
}
|
|
|
|
// Build MCP servers configuration
|
|
const mcpServers = realMcpTools.map((mcp: any) => ({
|
|
type: 'url' as const,
|
|
url: mcp.url.includes('{FIRECRAWL_API_KEY}')
|
|
? mcp.url.replace('{FIRECRAWL_API_KEY}', apiKeys.firecrawl || '')
|
|
: mcp.url,
|
|
name: mcp.name,
|
|
authorization_token: mcp.accessToken,
|
|
}));
|
|
|
|
const response = await client.beta.messages.create({
|
|
model: modelName,
|
|
max_tokens: 4096,
|
|
messages: messages as any,
|
|
mcp_servers: mcpServers as any,
|
|
betas: ['mcp-client-2025-04-04'],
|
|
} as any);
|
|
|
|
// Extract text and tool information from content
|
|
// Handle both standard tool_use and mcp_tool_use formats
|
|
const toolUses = response.content.filter((item: any) =>
|
|
item.type === 'tool_use' || item.type === 'mcp_tool_use'
|
|
);
|
|
const toolResults = response.content.filter((item: any) =>
|
|
item.type === 'tool_result' || item.type === 'mcp_tool_result'
|
|
);
|
|
const textBlocks = response.content.filter((item: any) => item.type === 'text');
|
|
|
|
responseText = textBlocks.map((item: any) => item.text).join('\n');
|
|
usage = (response.usage as any) || {};
|
|
|
|
// Format tool calls for logging and UI display
|
|
toolCalls = toolUses.map((item: any, idx: number) => {
|
|
const toolCall: any = {
|
|
type: item.type,
|
|
name: item.name,
|
|
server_name: item.server_name || 'MCP',
|
|
arguments: item.input, // Map 'input' to 'arguments' for UI compatibility
|
|
tool_use_id: item.id,
|
|
};
|
|
|
|
// Include tool result if available - extract output correctly for both formats
|
|
if (toolResults[idx]) {
|
|
const result = toolResults[idx] as any;
|
|
if (result.is_error) {
|
|
toolCall.output = { error: result.content };
|
|
} else if (Array.isArray(result.content)) {
|
|
toolCall.output = result.content[0]?.text || result.content;
|
|
} else {
|
|
toolCall.output = result.content;
|
|
}
|
|
}
|
|
|
|
return toolCall;
|
|
});
|
|
} else {
|
|
// Regular Anthropic call without MCP
|
|
const response = await client.messages.create({
|
|
model: modelName,
|
|
max_tokens: 4096,
|
|
messages: messages as any,
|
|
});
|
|
|
|
responseText = response.content[0].type === 'text' ? response.content[0].text : '';
|
|
usage = (response.usage as any) || {};
|
|
}
|
|
} else if (provider === 'openai' && apiKeys?.openai) {
|
|
const hasMcpTools = mcpTools && mcpTools.length > 0;
|
|
|
|
if (hasMcpTools) {
|
|
// Use native OpenAI SDK for function calling
|
|
const OpenAI = (await import('openai')).default;
|
|
const client = new OpenAI({ apiKey: apiKeys.openai });
|
|
|
|
// Convert MCP tools to OpenAI function format
|
|
const tools = mcpTools.map((mcp: any) => ({
|
|
type: "function" as const,
|
|
function: {
|
|
name: mcp.name || mcp.toolName || 'unknown_tool',
|
|
description: mcp.description || 'No description',
|
|
parameters: {
|
|
type: "object",
|
|
properties: mcp.schema?.properties || {},
|
|
required: mcp.schema?.required || []
|
|
}
|
|
}
|
|
}));
|
|
|
|
// First call with tools
|
|
const response = await client.chat.completions.create({
|
|
model: modelName,
|
|
messages: messages as any,
|
|
tools,
|
|
tool_choice: "auto"
|
|
});
|
|
|
|
const message = response.choices[0].message;
|
|
usage = (response.usage as unknown as LLMUsage) || ({} as LLMUsage);
|
|
|
|
// Handle tool calls
|
|
if (message.tool_calls && message.tool_calls.length > 0) {
|
|
// Execute MCP tools
|
|
const toolResults = await Promise.all(
|
|
message.tool_calls.map(async (call: any) => {
|
|
try {
|
|
// Find the MCP server for this tool
|
|
const mcpServer = mcpTools.find((m: any) =>
|
|
(m.name || m.toolName) === call.function.name
|
|
);
|
|
|
|
if (!mcpServer) {
|
|
throw new Error(`MCP server not found for tool: ${call.function.name}`);
|
|
}
|
|
|
|
// Parse arguments
|
|
const args = JSON.parse(call.function.arguments);
|
|
|
|
// Call MCP tool via HTTP
|
|
const mcpResponse = await fetch(mcpServer.url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(mcpServer.authToken && { 'Authorization': `Bearer ${mcpServer.authToken}` })
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: Date.now(),
|
|
method: 'tools/call',
|
|
params: {
|
|
name: call.function.name,
|
|
arguments: args
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await mcpResponse.json();
|
|
return {
|
|
tool_call_id: call.id,
|
|
role: "tool" as const,
|
|
content: JSON.stringify(result.result || result)
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
tool_call_id: call.id,
|
|
role: "tool" as const,
|
|
content: JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' })
|
|
};
|
|
}
|
|
})
|
|
);
|
|
|
|
// Second call with tool results
|
|
const finalResponse = await client.chat.completions.create({
|
|
model: modelName,
|
|
messages: [
|
|
...messages as any,
|
|
message,
|
|
...toolResults
|
|
]
|
|
});
|
|
|
|
responseText = finalResponse.choices[0].message.content || '';
|
|
usage = {
|
|
...usage,
|
|
prompt_tokens: (usage.prompt_tokens || 0) + (finalResponse.usage?.prompt_tokens || 0),
|
|
completion_tokens: (usage.completion_tokens || 0) + (finalResponse.usage?.completion_tokens || 0),
|
|
total_tokens: (usage.total_tokens || 0) + (finalResponse.usage?.total_tokens || 0),
|
|
};
|
|
|
|
// Track tool calls
|
|
toolCalls = message.tool_calls.map((call: any, idx) => ({
|
|
id: call.id,
|
|
name: call.function.name,
|
|
arguments: JSON.parse(call.function.arguments),
|
|
output: toolResults[idx] ? JSON.parse(toolResults[idx].content) : null
|
|
}));
|
|
} else {
|
|
responseText = message.content || '';
|
|
}
|
|
} else {
|
|
// Regular OpenAI call without MCP tools
|
|
const { ChatOpenAI } = await import('@langchain/openai');
|
|
const model = new ChatOpenAI({
|
|
apiKey: apiKeys.openai,
|
|
model: modelName,
|
|
});
|
|
|
|
const response = await model.invoke(messages);
|
|
responseText = response.content as string;
|
|
usage = response.response_metadata?.usage || {};
|
|
}
|
|
} else if (provider === 'groq' && apiKeys?.groq) {
|
|
const hasMcpTools = mcpTools && mcpTools.length > 0;
|
|
|
|
if (hasMcpTools) {
|
|
// Use Groq Responses API for MCP support
|
|
const OpenAI = (await import('openai')).default;
|
|
const client = new OpenAI({
|
|
apiKey: apiKeys.groq,
|
|
baseURL: 'https://api.groq.com/openai/v1',
|
|
});
|
|
|
|
// Convert MCP tools to Groq Responses API format
|
|
const tools = mcpTools.map((mcp: any) => ({
|
|
type: "mcp" as const,
|
|
server_label: mcp.name || mcp.toolName || 'unknown_tool',
|
|
server_url: mcp.url,
|
|
}));
|
|
|
|
// Use Responses API endpoint for MCP support
|
|
const response = await client.responses.create({
|
|
model: modelName,
|
|
input: messages[messages.length - 1].content as string,
|
|
tools,
|
|
} as any);
|
|
|
|
responseText = (response as any).output_text || '';
|
|
usage = (response as any).usage || {};
|
|
|
|
// Track tool calls if available
|
|
const outputs = (response as any).output || [];
|
|
toolCalls = outputs
|
|
.filter((o: any) => o.type === 'tool_use')
|
|
.map((o: any) => ({
|
|
id: o.id,
|
|
name: o.name,
|
|
arguments: o.input,
|
|
output: null,
|
|
}));
|
|
} else {
|
|
// Regular Groq chat completions for non-MCP calls
|
|
const { ChatOpenAI } = await import('@langchain/openai');
|
|
const model = new ChatOpenAI({
|
|
apiKey: apiKeys.groq,
|
|
model: modelName,
|
|
configuration: {
|
|
baseURL: 'https://api.groq.com/openai/v1',
|
|
},
|
|
});
|
|
|
|
const response = await model.invoke(messages);
|
|
responseText = response.content as string;
|
|
usage = response.response_metadata?.usage || {};
|
|
}
|
|
} else {
|
|
throw new Error(`No API key available for provider: ${provider}`);
|
|
}
|
|
|
|
// Prepare chat history updates (IMMUTABLE - don't mutate state)
|
|
const serverChatUpdates = data.includeChatHistory
|
|
? [
|
|
{ role: 'user', content: data.instructions || '' },
|
|
{ role: 'assistant', content: responseText },
|
|
]
|
|
: [];
|
|
|
|
let output: unknown = responseText;
|
|
if (data.outputFormat === 'JSON') {
|
|
try {
|
|
output = JSON.parse(responseText);
|
|
} catch (e) {
|
|
console.warn('Could not parse JSON output, using raw text');
|
|
}
|
|
}
|
|
|
|
// Return immutable updates (don't mutate state)
|
|
return {
|
|
__agentValue: output,
|
|
__agentToolCalls: toolCalls,
|
|
__chatHistoryUpdates: serverChatUpdates,
|
|
__variableUpdates: { lastOutput: output },
|
|
};
|
|
} catch (error) {
|
|
console.error('Agent execution error:', error);
|
|
|
|
// User-friendly error messages
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
if (errorMessage.includes('API key') || errorMessage.includes('api_key')) {
|
|
throw new Error('Missing API key. Please add your LLM provider key in Settings.');
|
|
}
|
|
|
|
if (errorMessage.includes('rate limit') || errorMessage.includes('429')) {
|
|
throw new Error('Rate limited. Please wait a moment and try again.');
|
|
}
|
|
|
|
if (errorMessage.includes('No API key available')) {
|
|
throw new Error('No API key configured. Please add an Anthropic, OpenAI, or Groq API key in your .env.local file.');
|
|
}
|
|
|
|
throw new Error(`Agent execution failed: ${errorMessage}`);
|
|
}
|
|
}
|
|
|