Add tests

This commit is contained in:
Sean Goedecke
2025-07-16 00:12:41 +00:00
parent 886d4717d7
commit 86c0691fbf
13 changed files with 9193 additions and 7695 deletions

31
src/helpers.ts Normal file
View File

@@ -0,0 +1,31 @@
import * as core from '@actions/core'
import * as fs from 'fs'
/**
* Helper function to load content from a file or use fallback input
* @param filePathInput - Input name for the file path
* @param contentInput - Input name for the direct content
* @param defaultValue - Default value to use if neither file nor content is provided
* @returns The loaded content
*/
export function loadContentFromFileOrInput(
filePathInput: string,
contentInput: string,
defaultValue?: string
): string {
const filePath = core.getInput(filePathInput)
const contentString = core.getInput(contentInput)
if (filePath !== undefined && filePath !== '') {
if (!fs.existsSync(filePath)) {
throw new Error(`File for ${filePathInput} was not found: ${filePath}`)
}
return fs.readFileSync(filePath, 'utf-8')
} else if (contentString !== undefined && contentString !== '') {
return contentString
} else if (defaultValue !== undefined) {
return defaultValue
} else {
throw new Error(`Neither ${filePathInput} nor ${contentInput} was set`)
}
}

156
src/inference.ts Normal file
View File

@@ -0,0 +1,156 @@
import * as core from '@actions/core'
import ModelClient, { isUnexpected } from '@azure-rest/ai-inference'
import { AzureKeyCredential } from '@azure/core-auth'
import { MCPClient, executeToolCalls } from './mcp.js'
export interface InferenceRequest {
systemPrompt: string
prompt: string
modelName: string
maxTokens: number
endpoint: string
token: string
}
export interface InferenceResponse {
content: string | null
toolCalls?: any[]
}
/**
* Simple one-shot inference without tools
*/
export async function simpleInference(
request: InferenceRequest
): Promise<string | null> {
core.info('Running simple inference without tools')
const client = ModelClient(
request.endpoint,
new AzureKeyCredential(request.token),
{
userAgentOptions: { userAgentPrefix: 'github-actions-ai-inference' }
}
)
const requestBody = {
messages: [
{
role: 'system',
content: request.systemPrompt
},
{ role: 'user', content: request.prompt }
],
max_tokens: request.maxTokens,
model: request.modelName
}
const response = await client.path('/chat/completions').post({
body: requestBody
})
if (isUnexpected(response)) {
throw new Error(
'An error occurred while fetching the response (' +
response.status +
'): ' +
response.body
)
}
const modelResponse = response.body.choices[0].message.content
core.info(`Model response: ${modelResponse || 'No response content'}`)
return modelResponse
}
/**
* MCP-enabled inference with tool execution loop
*/
export async function mcpInference(
request: InferenceRequest,
mcpClient: MCPClient
): Promise<string | null> {
core.info('Running MCP inference with tools')
const client = ModelClient(
request.endpoint,
new AzureKeyCredential(request.token),
{
userAgentOptions: { userAgentPrefix: 'github-actions-ai-inference' }
}
)
// Start with the initial conversation
let messages: any[] = [
{
role: 'system',
content: request.systemPrompt
},
{ role: 'user', content: request.prompt }
]
let iterationCount = 0
const maxIterations = 5 // Prevent infinite loops
while (iterationCount < maxIterations) {
iterationCount++
core.info(`MCP inference iteration ${iterationCount}`)
const requestBody = {
messages: messages,
max_tokens: request.maxTokens,
model: request.modelName,
tools: mcpClient.tools
}
const response = await client.path('/chat/completions').post({
body: requestBody
})
if (isUnexpected(response)) {
throw new Error(
'An error occurred while fetching the response (' +
response.status +
'): ' +
response.body
)
}
const assistantMessage = response.body.choices[0].message
const modelResponse = assistantMessage.content
const toolCalls = assistantMessage.tool_calls
core.info(`Model response: ${modelResponse || 'No response content'}`)
messages.push({
role: 'assistant',
content: modelResponse,
...(toolCalls && { tool_calls: toolCalls })
})
if (!toolCalls || toolCalls.length === 0) {
core.info('No tool calls requested, ending MCP inference loop')
return modelResponse
}
core.info(`Model requested ${toolCalls.length} tool calls`)
const toolResults = await executeToolCalls(mcpClient.client, toolCalls)
messages.push(...toolResults)
core.info('Tool results added, continuing conversation...')
}
core.warning(
`MCP inference loop exceeded maximum iterations (${maxIterations})`
)
// Return the last assistant message content
const lastAssistantMessage = messages
.slice()
.reverse()
.find((msg) => msg.role === 'assistant')
return lastAssistantMessage?.content || null
}

View File

@@ -1,43 +1,13 @@
import * as core from '@actions/core'
import ModelClient, { isUnexpected } from '@azure-rest/ai-inference'
import { AzureKeyCredential } from '@azure/core-auth'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { connectToMCP } from './mcp.js'
import { simpleInference, mcpInference, InferenceRequest } from './inference.js'
import { loadContentFromFileOrInput } from './helpers.js'
const RESPONSE_FILE = 'modelResponse.txt'
/**
* Helper function to load content from a file or use fallback input
* @param filePathInput - Input name for the file path
* @param contentInput - Input name for the direct content
* @param defaultValue - Default value to use if neither file nor content is provided
* @returns The loaded content
*/
function loadContentFromFileOrInput(
filePathInput: string,
contentInput: string,
defaultValue?: string
): string {
const filePath = core.getInput(filePathInput)
const contentString = core.getInput(contentInput)
if (filePath !== undefined && filePath !== '') {
if (!fs.existsSync(filePath)) {
throw new Error(`File for ${filePathInput} was not found: ${filePath}`)
}
return fs.readFileSync(filePath, 'utf-8')
} else if (contentString !== undefined && contentString !== '') {
return contentString
} else if (defaultValue !== undefined) {
return defaultValue
} else {
throw new Error(`Neither ${filePathInput} nor ${contentInput} was set`)
}
}
/**
* The main function for the action.
*
@@ -45,10 +15,8 @@ function loadContentFromFileOrInput(
*/
export async function run(): Promise<void> {
try {
// Load prompt content - required
const prompt = loadContentFromFileOrInput('prompt-file', 'prompt')
// Load system prompt with default value
const systemPrompt = loadContentFromFileOrInput(
'system-prompt-file',
'system-prompt',
@@ -64,200 +32,34 @@ export async function run(): Promise<void> {
}
const endpoint = core.getInput('endpoint')
// Get MCP server configuration
const mcpServerUrl = 'https://api.githubcopilot.com/mcp/'
const enableMcp = core.getBooleanInput('enable-mcp') || false
let azureTools: any[] = []
let mcp: Client | null = null
const inferenceRequest: InferenceRequest = {
systemPrompt,
prompt,
modelName,
maxTokens,
endpoint,
token
}
// Connect to MCP server if enabled
if (enableMcp || true) {
core.info('Connecting to GitHub MCP server...' + token)
let modelResponse: string | null = null
const transport = new StreamableHTTPClientTransport(
new URL(mcpServerUrl),
{
requestInit: {
headers: {
Authorization: `Bearer ${token}`
}
}
}
)
if (enableMcp) {
const mcpClient = await connectToMCP(token)
mcp = new Client({
name: 'ai-inference-action',
version: '1.0.0',
transport
})
try {
await mcp.connect(transport)
} catch (mcpError) {
core.warning(`Failed to connect to MCP server: ${mcpError}`)
// Continue without tools if MCP connection fails
return
if (mcpClient) {
modelResponse = await mcpInference(inferenceRequest, mcpClient)
} else {
core.warning('MCP connection failed, falling back to simple inference')
modelResponse = await simpleInference(inferenceRequest)
}
core.info('Successfully connected to MCP server')
// Pull tool metadata
const tools = await mcp.listTools()
core.info(`Retrieved ${tools.tools?.length || 0} tools from MCP server`)
// Map MCP → Azure tool definitions
azureTools = (tools.tools || []).map((t) => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.inputSchema
}
}))
core.info(`Mapped ${azureTools.length} tools for Azure AI Inference`)
} else {
modelResponse = await simpleInference(inferenceRequest)
}
const client = ModelClient(endpoint, new AzureKeyCredential(token), {
userAgentOptions: { userAgentPrefix: 'github-actions-ai-inference' }
})
const requestBody: any = {
messages: [
{
role: 'system',
content: systemPrompt
},
{ role: 'user', content: prompt }
],
max_tokens: maxTokens,
model: modelName
}
// Add tools if available
if (azureTools.length > 0) {
requestBody.tools = azureTools
}
const response = await client.path('/chat/completions').post({
body: requestBody
})
if (isUnexpected(response)) {
throw new Error(
'An error occurred while fetching the response (' +
response.status +
'): ' +
response.body
)
}
let modelResponse: string | null =
response.body.choices[0].message.content
core.info(`Model response: ${response || 'No response content'}`)
// Handle tool calls if present
const toolCalls = response.body.choices[0].message.tool_calls
if (toolCalls && toolCalls.length > 0 && mcp) {
core.info(`Model requested ${toolCalls.length} tool calls`)
// Execute tool calls via MCP and continue the conversation
const toolResults: any[] = []
for (const toolCall of toolCalls) {
core.info(
`Executing tool: ${toolCall.function.name} with args: ${toolCall.function.arguments}`
)
try {
// Parse the arguments from JSON string
const args = JSON.parse(toolCall.function.arguments)
// Call the tool via MCP
const result = await mcp.callTool({
name: toolCall.function.name,
arguments: args
})
core.info(`Tool ${toolCall.function.name} executed successfully`)
// Store the result for the follow-up conversation
toolResults.push({
tool_call_id: toolCall.id,
role: 'tool',
name: toolCall.function.name,
content: JSON.stringify(result.content)
})
} catch (toolError) {
core.warning(`Failed to execute tool ${toolCall.function.name}: ${toolError}`)
// Add error result to continue conversation
toolResults.push({
tool_call_id: toolCall.id,
role: 'tool',
name: toolCall.function.name,
content: `Error: ${toolError}`
})
}
}
// If we have tool results, continue the conversation
if (toolResults.length > 0) {
core.info('Continuing conversation with tool results...')
// Build the follow-up request with the original conversation + tool results
const followUpMessages = [
{
role: 'system',
content: systemPrompt
},
{ role: 'user', content: prompt },
{
role: 'assistant',
content: modelResponse,
tool_calls: toolCalls
},
...toolResults
]
const followUpRequest: any = {
messages: followUpMessages,
max_tokens: maxTokens,
model: modelName
}
// Add tools again for potential follow-up tool calls
if (azureTools.length > 0) {
followUpRequest.tools = azureTools
}
const followUpResponse = await client.path('/chat/completions').post({
body: followUpRequest
})
if (isUnexpected(followUpResponse)) {
core.warning(
'Failed to get follow-up response after tool execution: ' +
followUpResponse.status + ': ' + followUpResponse.body
)
} else {
const finalResponse = followUpResponse.body.choices[0].message.content
core.info(`Final response after tool execution: ${finalResponse}`)
// Update the model response to the final one
modelResponse = finalResponse || modelResponse
}
}
}
// Set outputs for other workflow steps to use
core.setOutput('response', modelResponse || '')
// Save the response to a file in case the response overflow the output limit
const responseFilePath = path.join(tempDir(), RESPONSE_FILE)
core.setOutput('response-file', responseFilePath)
@@ -265,7 +67,6 @@ export async function run(): Promise<void> {
fs.writeFileSync(responseFilePath, modelResponse, 'utf-8')
}
} catch (error) {
// Fail the workflow run if an error occurs
if (error instanceof Error) {
core.setFailed(error.message)
} else {

129
src/mcp.ts Normal file
View File

@@ -0,0 +1,129 @@
import * as core from '@actions/core'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
export interface ToolResult {
tool_call_id: string
role: 'tool'
name: string
content: string
}
export interface MCPClient {
client: Client
tools: any[]
}
/**
* Connect to the MCP server and retrieve available tools
*/
export async function connectToMCP(token: string): Promise<MCPClient | null> {
const mcpServerUrl = 'https://api.githubcopilot.com/mcp/'
core.info('Connecting to GitHub MCP server...')
const transport = new StreamableHTTPClientTransport(new URL(mcpServerUrl), {
requestInit: {
headers: {
Authorization: `Bearer ${token}`
}
}
})
const client = new Client({
name: 'ai-inference-action',
version: '1.0.0',
transport
})
try {
await client.connect(transport)
} catch (mcpError) {
core.warning(`Failed to connect to MCP server: ${mcpError}`)
return null
}
core.info('Successfully connected to MCP server')
// Pull tool metadata
const toolsResponse = await client.listTools()
core.info(
`Retrieved ${toolsResponse.tools?.length || 0} tools from MCP server`
)
// Map MCP → Azure tool definitions
const tools = (toolsResponse.tools || []).map((t) => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.inputSchema
}
}))
core.info(`Mapped ${tools.length} tools for Azure AI Inference`)
return { client, tools }
}
/**
* Execute a single tool call via MCP
*/
export async function executeToolCall(
mcpClient: Client,
toolCall: any
): Promise<ToolResult> {
core.info(
`Executing tool: ${toolCall.function.name} with args: ${toolCall.function.arguments}`
)
try {
// Parse the arguments from JSON string
const args = JSON.parse(toolCall.function.arguments)
// Call the tool via MCP
const result = await mcpClient.callTool({
name: toolCall.function.name,
arguments: args
})
core.info(`Tool ${toolCall.function.name} executed successfully`)
// Return the result formatted for the conversation
return {
tool_call_id: toolCall.id,
role: 'tool',
name: toolCall.function.name,
content: JSON.stringify(result.content)
}
} catch (toolError) {
core.warning(
`Failed to execute tool ${toolCall.function.name}: ${toolError}`
)
// Return error result to continue conversation
return {
tool_call_id: toolCall.id,
role: 'tool',
name: toolCall.function.name,
content: `Error: ${toolError}`
}
}
}
/**
* Execute all tool calls from a response
*/
export async function executeToolCalls(
mcpClient: Client,
toolCalls: any[]
): Promise<ToolResult[]> {
const toolResults: ToolResult[] = []
for (const toolCall of toolCalls) {
const result = await executeToolCall(mcpClient, toolCall)
toolResults.push(result)
}
return toolResults
}