Support .prompt.yml files

This commit is contained in:
Sean Goedecke
2025-07-21 00:11:26 +00:00
parent 0479ac822e
commit 1780121e3b
17 changed files with 3889 additions and 129 deletions
+87 -13
View File
@@ -36,17 +36,90 @@ jobs:
### Using a prompt file
You can also provide a prompt file instead of an inline prompt:
You can also provide a prompt file instead of an inline prompt. The action
supports both plain text files and structured `.prompt.yml` files:
```yaml
steps:
- name: Run AI Inference with Prompt File
- name: Run AI Inference with Text File
id: inference
uses: actions/ai-inference@v1
with:
prompt-file: './path/to/prompt.txt'
```
### Using GitHub prompt.yml files
For more advanced use cases, you can use structured `.prompt.yml` files that
support templating, custom models, and JSON schema responses:
```yaml
steps:
- name: Run AI Inference with Prompt YAML
id: inference
uses: actions/ai-inference@v1
with:
prompt-file: './.github/prompts/sample.prompt.yml'
input: |
var1: hello
var2: ${{ steps.some-step.outputs.output }}
var3: |
Lorem Ipsum
Hello World
```
#### Simple prompt.yml example
```yaml
messages:
- role: system
content: Be as concise as possible
- role: user
content: 'Compare {{a}} and {{b}}, please'
model: openai/gpt-4o
```
#### Prompt.yml with JSON schema support
```yaml
messages:
- role: system
content:
You are a helpful assistant that describes animals using JSON format
- role: user
content: |-
Describe a {{animal}}
Use JSON format as specified in the response schema
model: openai/gpt-4o
responseFormat: json_schema
jsonSchema: |-
{
"name": "describe_animal",
"strict": true,
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the animal"
},
"habitat": {
"type": "string",
"description": "The habitat the animal lives in"
}
},
"additionalProperties": false,
"required": [
"name",
"habitat"
]
}
}
```
Variables in prompt.yml files are templated using `{{variable}}` format and are
supplied via the `input` parameter in YAML format.
### Using a system prompt file
In addition to the regular prompt, you can provide a system prompt file instead
@@ -107,17 +180,18 @@ GitHub permissions for the operations the AI will perform.
Various inputs are defined in [`action.yml`](action.yml) to let you configure
the action:
| Name | Description | Default |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
| `prompt` | The prompt to send to the model | N/A |
| `prompt-file` | Path to a file containing the prompt. If both `prompt` and `prompt-file` are provided, `prompt-file` takes precedence | `""` |
| `system-prompt` | The system prompt to send to the model | `"You are a helpful assistant"` |
| `system-prompt-file` | Path to a file containing the system prompt. If both `system-prompt` and `system-prompt-file` are provided, `system-prompt-file` takes precedence | `""` |
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `openai/gpt-4.1` |
| `endpoint` | The endpoint to use for inference. If you're running this as part of an org, you should probably use the org-specific Models endpoint | `https://models.github.ai/inference` |
| `max-tokens` | The max number of tokens to generate | 200 |
| `enable-github-mcp` | Enable Model Context Protocol integration with GitHub tools | `false` |
| Name | Description | Default |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
| `prompt` | The prompt to send to the model | N/A |
| `prompt-file` | Path to a file containing the prompt (supports .txt and .prompt.yml formats). If both `prompt` and `prompt-file` are provided, `prompt-file` takes precedence | `""` |
| `input` | Template variables in YAML format for .prompt.yml files (e.g., `var1: value1` on separate lines) | `""` |
| `system-prompt` | The system prompt to send to the model | `"You are a helpful assistant"` |
| `system-prompt-file` | Path to a file containing the system prompt. If both `system-prompt` and `system-prompt-file` are provided, `system-prompt-file` takes precedence | `""` |
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `openai/gpt-4o` |
| `endpoint` | The endpoint to use for inference. If you're running this as part of an org, you should probably use the org-specific Models endpoint | `https://models.github.ai/inference` |
| `max-tokens` | The max number of tokens to generate | 200 |
| `enable-github-mcp` | Enable Model Context Protocol integration with GitHub tools | `false` |
## Outputs
@@ -0,0 +1,41 @@
messages:
- role: system
content:
You are a helpful assistant that describes animals using JSON format
- role: user
content: |-
Describe a {{animal}}
Use JSON format as specified in the response schema
model: openai/gpt-4o
responseFormat: json_schema
jsonSchema: |-
{
"name": "describe_animal",
"strict": true,
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the animal"
},
"habitat": {
"type": "string",
"description": "The habitat the animal lives in"
},
"characteristics": {
"type": "array",
"items": {
"type": "string"
},
"description": "Key characteristics of the animal"
}
},
"additionalProperties": false,
"required": [
"name",
"habitat",
"characteristics"
]
}
}
+6
View File
@@ -0,0 +1,6 @@
messages:
- role: system
content: Be as concise as possible
- role: user
content: 'Compare {{a}} and {{b}}, please'
model: openai/gpt-4o
+163
View File
@@ -0,0 +1,163 @@
import { describe, it, expect } from '@jest/globals'
import {
buildMessages,
buildResponseFormat,
buildInferenceRequest
} from '../src/helpers'
import { PromptConfig } from '../src/prompt'
describe('helpers.ts - inference request building', () => {
describe('buildMessages', () => {
it('should build messages from prompt config', () => {
const promptConfig: PromptConfig = {
messages: [
{ role: 'system', content: 'System message' },
{ role: 'user', content: 'User message' }
]
}
const result = buildMessages(promptConfig)
expect(result).toEqual([
{ role: 'system', content: 'System message' },
{ role: 'user', content: 'User message' }
])
})
it('should build messages from legacy format', () => {
const result = buildMessages(undefined, 'System prompt', 'User prompt')
expect(result).toEqual([
{ role: 'system', content: 'System prompt' },
{ role: 'user', content: 'User prompt' }
])
})
it('should use default system prompt when none provided', () => {
const result = buildMessages(undefined, undefined, 'User prompt')
expect(result).toEqual([
{ role: 'system', content: 'You are a helpful assistant' },
{ role: 'user', content: 'User prompt' }
])
})
})
describe('buildResponseFormat', () => {
it('should build JSON schema response format', () => {
const promptConfig: PromptConfig = {
messages: [],
responseFormat: 'json_schema',
jsonSchema: JSON.stringify({
name: 'test_schema',
schema: { type: 'object' }
})
}
const result = buildResponseFormat(promptConfig)
expect(result).toEqual({
type: 'json_schema',
json_schema: {
name: 'test_schema',
schema: { type: 'object' }
}
})
})
it('should return undefined for text format', () => {
const promptConfig: PromptConfig = {
messages: [],
responseFormat: 'text'
}
const result = buildResponseFormat(promptConfig)
expect(result).toBeUndefined()
})
it('should return undefined when no response format specified', () => {
const promptConfig: PromptConfig = {
messages: []
}
const result = buildResponseFormat(promptConfig)
expect(result).toBeUndefined()
})
it('should throw error for invalid JSON schema', () => {
const promptConfig: PromptConfig = {
messages: [],
responseFormat: 'json_schema',
jsonSchema: 'invalid json'
}
expect(() => buildResponseFormat(promptConfig)).toThrow(
'Invalid JSON schema'
)
})
})
describe('buildInferenceRequest', () => {
it('should build complete inference request from prompt config', () => {
const promptConfig: PromptConfig = {
messages: [
{ role: 'system', content: 'System message' },
{ role: 'user', content: 'User message' }
],
responseFormat: 'json_schema',
jsonSchema: JSON.stringify({
name: 'test_schema',
schema: { type: 'object' }
})
}
const result = buildInferenceRequest(
promptConfig,
undefined,
undefined,
'gpt-4',
100,
'https://api.test.com',
'test-token'
)
expect(result).toEqual({
messages: [
{ role: 'system', content: 'System message' },
{ role: 'user', content: 'User message' }
],
modelName: 'gpt-4',
maxTokens: 100,
endpoint: 'https://api.test.com',
token: 'test-token',
responseFormat: {
type: 'json_schema',
json_schema: {
name: 'test_schema',
schema: { type: 'object' }
}
}
})
})
it('should build inference request from legacy format', () => {
const result = buildInferenceRequest(
undefined,
'System prompt',
'User prompt',
'gpt-4',
100,
'https://api.test.com',
'test-token'
)
expect(result).toEqual({
messages: [
{ role: 'system', content: 'System prompt' },
{ role: 'user', content: 'User prompt' }
],
modelName: 'gpt-4',
maxTokens: 100,
endpoint: 'https://api.test.com',
token: 'test-token',
responseFormat: undefined
})
})
})
})
+4 -2
View File
@@ -33,8 +33,10 @@ const { simpleInference, mcpInference } = await import('../src/inference.js')
describe('inference.ts', () => {
const mockRequest = {
systemPrompt: 'You are a test assistant',
prompt: 'Hello, AI!',
messages: [
{ role: 'system', content: 'You are a test assistant' },
{ role: 'user', content: 'Hello, AI!' }
],
modelName: 'gpt-4',
maxTokens: 100,
endpoint: 'https://api.test.com',
+173
View File
@@ -0,0 +1,173 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
import * as core from '@actions/core'
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import { run } from '../src/main'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Mock the action toolkit functions
jest.mock('@actions/core')
// Mock fs to handle temporary file creation
jest.mock('fs')
// Mock the inference functions
jest.mock('../src/inference', () => ({
simpleInference: jest.fn(),
mcpInference: jest.fn()
}))
// Mock the MCP connection
jest.mock('../src/mcp', () => ({
connectToGitHubMCP: jest.fn()
}))
import { simpleInference } from '../src/inference'
describe('main.ts - prompt.yml integration', () => {
beforeEach(() => {
jest.clearAllMocks()
// Mock environment variables
process.env['GITHUB_TOKEN'] = 'test-token'
// Mock core.getInput to return appropriate values
const mockGetInput = core.getInput as jest.Mock
mockGetInput.mockImplementation((name: string) => {
switch (name) {
case 'model':
return 'openai/gpt-4o'
case 'max-tokens':
return '200'
case 'endpoint':
return 'https://models.github.ai/inference'
case 'enable-github-mcp':
return 'false'
default:
return ''
}
})
// Mock core.getBooleanInput
const mockGetBooleanInput = core.getBooleanInput as jest.Mock
mockGetBooleanInput.mockReturnValue(false)
// Mock fs.existsSync
const mockExistsSync = fs.existsSync as jest.Mock
mockExistsSync.mockReturnValue(true)
// Mock fs.readFileSync for prompt file
const mockReadFileSync = fs.readFileSync as jest.Mock
mockReadFileSync.mockReturnValue(`
messages:
- role: system
content: Be as concise as possible
- role: user
content: 'Compare {{a}} and {{b}}, please'
model: openai/gpt-4o
`)
// Mock fs.writeFileSync
const mockWriteFileSync = fs.writeFileSync as jest.Mock
mockWriteFileSync.mockImplementation(() => {})
// Mock simpleInference
const mockSimpleInference = simpleInference as jest.Mock
mockSimpleInference.mockResolvedValue('Mocked AI response')
})
it('should handle prompt YAML files with template variables', async () => {
const mockGetInput = core.getInput as jest.Mock
mockGetInput.mockImplementation((name: string) => {
switch (name) {
case 'prompt-file':
return 'test.prompt.yml'
case 'input':
return 'a: cats\nb: dogs'
case 'model':
return 'openai/gpt-4o'
case 'max-tokens':
return '200'
case 'endpoint':
return 'https://models.github.ai/inference'
case 'enable-github-mcp':
return 'false'
default:
return ''
}
})
await run()
// Verify simpleInference was called with the correct message structure
const mockSimpleInference = simpleInference as jest.Mock
expect(mockSimpleInference).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{
role: 'system',
content: 'Be as concise as possible'
},
{
role: 'user',
content: 'Compare cats and dogs, please'
}
],
modelName: 'openai/gpt-4o',
maxTokens: 200,
endpoint: 'https://models.github.ai/inference',
token: 'test-token'
})
)
// Verify outputs were set
expect(core.setOutput).toHaveBeenCalledWith(
'response',
'Mocked AI response'
)
expect(core.setOutput).toHaveBeenCalledWith(
'response-file',
expect.any(String)
)
})
it('should fall back to legacy format when not using prompt YAML', async () => {
const mockGetInput = core.getInput as jest.Mock
mockGetInput.mockImplementation((name: string) => {
switch (name) {
case 'prompt':
return 'Hello, world!'
case 'system-prompt':
return 'You are helpful'
case 'model':
return 'openai/gpt-4o'
case 'max-tokens':
return '200'
case 'endpoint':
return 'https://models.github.ai/inference'
case 'enable-github-mcp':
return 'false'
default:
return ''
}
})
await run()
// Verify simpleInference was called with legacy format
const mockSimpleInference = simpleInference as jest.Mock
expect(mockSimpleInference).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: 'You are helpful',
prompt: 'Hello, world!',
modelName: 'openai/gpt-4o',
maxTokens: 200,
endpoint: 'https://models.github.ai/inference',
token: 'test-token'
})
)
})
})
+16 -8
View File
@@ -161,12 +161,15 @@ describe('main.ts', () => {
await run()
expect(mockSimpleInference).toHaveBeenCalledWith({
systemPrompt: 'You are a test assistant.',
prompt: 'Hello, AI!',
messages: [
{ role: 'system', content: 'You are a test assistant.' },
{ role: 'user', content: 'Hello, AI!' }
],
modelName: 'gpt-4',
maxTokens: 100,
endpoint: 'https://api.test.com',
token: 'fake-token'
token: 'fake-token',
responseFormat: undefined
})
expect(mockConnectToGitHubMCP).not.toHaveBeenCalled()
expect(mockMcpInference).not.toHaveBeenCalled()
@@ -193,8 +196,10 @@ describe('main.ts', () => {
expect(mockConnectToGitHubMCP).toHaveBeenCalledWith('fake-token')
expect(mockMcpInference).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: 'You are a test assistant.',
prompt: 'Hello, AI!',
messages: [
{ role: 'system', content: 'You are a test assistant.' },
{ role: 'user', content: 'Hello, AI!' }
],
token: 'fake-token'
}),
mockMcpClient
@@ -243,12 +248,15 @@ describe('main.ts', () => {
await run()
expect(mockSimpleInference).toHaveBeenCalledWith({
systemPrompt: systemPromptContent,
prompt: promptContent,
messages: [
{ role: 'system', content: systemPromptContent },
{ role: 'user', content: promptContent }
],
modelName: 'gpt-4',
maxTokens: 100,
endpoint: 'https://api.test.com',
token: 'fake-token'
token: 'fake-token',
responseFormat: undefined
})
verifyStandardResponse()
})
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect } from '@jest/globals'
import * as path from 'path'
import { fileURLToPath } from 'url'
import {
parseTemplateVariables,
replaceTemplateVariables,
loadPromptFile,
isPromptYamlFile
} from '../src/prompt'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
describe('prompt.ts', () => {
describe('parseTemplateVariables', () => {
it('should parse simple YAML variables', () => {
const input = `
a: hello
b: world
`
const result = parseTemplateVariables(input)
expect(result).toEqual({ a: 'hello', b: 'world' })
})
it('should parse multiline variables', () => {
const input = `
var1: hello
var2: |
This is a
multiline string
`
const result = parseTemplateVariables(input)
expect(result.var1).toBe('hello')
expect(result.var2).toContain('This is a')
expect(result.var2).toContain('multiline string')
})
it('should return empty object for empty input', () => {
const result = parseTemplateVariables('')
expect(result).toEqual({})
})
it('should throw error for invalid YAML', () => {
const input = 'invalid: yaml: content:'
expect(() => parseTemplateVariables(input)).toThrow()
})
})
describe('replaceTemplateVariables', () => {
it('should replace simple variables', () => {
const text = 'Hello {{name}}, welcome to {{place}}!'
const variables = { name: 'John', place: 'GitHub' }
const result = replaceTemplateVariables(text, variables)
expect(result).toBe('Hello John, welcome to GitHub!')
})
it('should leave unreplaced variables as is', () => {
const text = 'Hello {{name}}, welcome to {{unknown}}!'
const variables = { name: 'John' }
const result = replaceTemplateVariables(text, variables)
expect(result).toBe('Hello John, welcome to {{unknown}}!')
})
it('should handle no variables', () => {
const text = 'No variables here'
const variables = {}
const result = replaceTemplateVariables(text, variables)
expect(result).toBe('No variables here')
})
})
describe('isPromptYamlFile', () => {
it('should detect .prompt.yml files', () => {
expect(isPromptYamlFile('test.prompt.yml')).toBe(true)
expect(isPromptYamlFile('path/to/test.prompt.yml')).toBe(true)
})
it('should detect .prompt.yaml files', () => {
expect(isPromptYamlFile('test.prompt.yaml')).toBe(true)
expect(isPromptYamlFile('path/to/test.prompt.yaml')).toBe(true)
})
it('should reject other file types', () => {
expect(isPromptYamlFile('test.txt')).toBe(false)
expect(isPromptYamlFile('test.yml')).toBe(false)
expect(isPromptYamlFile('test.yaml')).toBe(false)
expect(isPromptYamlFile('test.prompt')).toBe(false)
})
})
describe('loadPromptFile', () => {
it('should load simple prompt file', () => {
const filePath = path.join(
__dirname,
'../__fixtures__/prompts/simple.prompt.yml'
)
const variables = { a: 'cats', b: 'dogs' }
const result = loadPromptFile(filePath, variables)
expect(result.messages).toHaveLength(2)
expect(result.messages[0]).toEqual({
role: 'system',
content: 'Be as concise as possible'
})
expect(result.messages[1]).toEqual({
role: 'user',
content: 'Compare cats and dogs, please'
})
expect(result.model).toBe('openai/gpt-4o')
})
it('should load JSON schema prompt file', () => {
const filePath = path.join(
__dirname,
'../__fixtures__/prompts/json-schema.prompt.yml'
)
const variables = { animal: 'dog' }
const result = loadPromptFile(filePath, variables)
expect(result.messages).toHaveLength(2)
expect(result.messages[1].content).toContain('Describe a dog')
expect(result.responseFormat).toBe('json_schema')
expect(result.jsonSchema).toBeDefined()
expect(result.jsonSchema).toContain('describe_animal')
})
it('should throw error for non-existent file', () => {
expect(() => loadPromptFile('non-existent.prompt.yml')).toThrow(
'Prompt file not found'
)
})
})
})
+7 -1
View File
@@ -14,7 +14,13 @@ inputs:
required: false
default: ''
prompt-file:
description: Path to a file containing the prompt
description:
Path to a file containing the prompt (supports .txt and .prompt.yml
formats)
required: false
default: ''
input:
description: Template variables in YAML format for .prompt.yml files
required: false
default: ''
model:
Generated Vendored
+3001 -69
View File
File diff suppressed because it is too large Load Diff
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -12,6 +12,8 @@
"@actions/core": "^1.11.1",
"@modelcontextprotocol/sdk": "^1.15.1",
"@rollup/plugin-json": "^6.1.0",
"@types/js-yaml": "^4.0.9",
"js-yaml": "^4.1.0",
"pkce-challenge": "^5.0.0"
},
"devDependencies": {
@@ -4407,6 +4409,12 @@
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -5173,7 +5181,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-buffer-byte-length": {
@@ -10254,7 +10261,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
+2
View File
@@ -40,6 +40,8 @@
"@actions/core": "^1.11.1",
"@modelcontextprotocol/sdk": "^1.15.1",
"@rollup/plugin-json": "^6.1.0",
"@types/js-yaml": "^4.0.9",
"js-yaml": "^4.1.0",
"pkce-challenge": "^5.0.0"
},
"devDependencies": {
+76
View File
@@ -1,6 +1,8 @@
import * as core from '@actions/core'
import { GetChatCompletionsDefaultResponse } from '@azure-rest/ai-inference'
import * as fs from 'fs'
import { PromptConfig } from './prompt.js'
import { InferenceRequest } from './inference.js'
/**
* Helper function to load content from a file or use fallback input
@@ -64,3 +66,77 @@ export function handleUnexpectedResponse(
: JSON.stringify(response.body))
)
}
/**
* Build messages array from either prompt config or legacy format
*/
export function buildMessages(
promptConfig?: PromptConfig,
systemPrompt?: string,
prompt?: string
): Array<{ role: string; content: string }> {
if (promptConfig?.messages && promptConfig.messages.length > 0) {
// Use new message format
return promptConfig.messages.map((msg) => ({
role: msg.role,
content: msg.content
}))
} else {
// Use legacy format
return [
{
role: 'system',
content: systemPrompt || 'You are a helpful assistant'
},
{ role: 'user', content: prompt || '' }
]
}
}
/**
* Build response format object for API from prompt config
*/
export function buildResponseFormat(promptConfig?: PromptConfig): any {
if (
promptConfig?.responseFormat === 'json_schema' &&
promptConfig.jsonSchema
) {
try {
const schema = JSON.parse(promptConfig.jsonSchema)
return {
type: 'json_schema',
json_schema: schema
}
} catch (error) {
throw new Error(
`Invalid JSON schema: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
return undefined
}
/**
* Build complete InferenceRequest from prompt config and inputs
*/
export function buildInferenceRequest(
promptConfig: PromptConfig | undefined,
systemPrompt: string | undefined,
prompt: string | undefined,
modelName: string,
maxTokens: number,
endpoint: string,
token: string
): InferenceRequest {
const messages = buildMessages(promptConfig, systemPrompt, prompt)
const responseFormat = buildResponseFormat(promptConfig)
return {
messages,
modelName,
maxTokens,
endpoint,
token,
responseFormat
}
}
+17 -19
View File
@@ -5,12 +5,12 @@ import { GitHubMCPClient, executeToolCalls } from './mcp.js'
import { handleUnexpectedResponse } from './helpers.js'
export interface InferenceRequest {
systemPrompt: string
prompt: string
messages: Array<{ role: string; content: string }>
modelName: string
maxTokens: number
endpoint: string
token: string
responseFormat?: any // Will contain the processed response format for the API
}
export interface InferenceResponse {
@@ -41,18 +41,17 @@ export async function simpleInference(
}
)
const requestBody = {
messages: [
{
role: 'system',
content: request.systemPrompt
},
{ role: 'user', content: request.prompt }
],
const requestBody: any = {
messages: request.messages,
max_tokens: request.maxTokens,
model: request.modelName
}
// Add response format if specified
if (request.responseFormat) {
requestBody.response_format = request.responseFormat
}
const response = await client.path('/chat/completions').post({
body: requestBody
})
@@ -84,14 +83,8 @@ export async function mcpInference(
}
)
// Start with the initial conversation
const messages = [
{
role: 'system',
content: request.systemPrompt
},
{ role: 'user', content: request.prompt }
]
// Start with the pre-processed messages
const messages: Array<any> = [...request.messages]
let iterationCount = 0
const maxIterations = 5 // Prevent infinite loops
@@ -100,13 +93,18 @@ export async function mcpInference(
iterationCount++
core.info(`MCP inference iteration ${iterationCount}`)
const requestBody = {
const requestBody: any = {
messages: messages,
max_tokens: request.maxTokens,
model: request.modelName,
tools: githubMcpClient.tools
}
// Add response format if specified (only on first iteration to avoid conflicts)
if (iterationCount === 1 && request.responseFormat) {
requestBody.response_format = request.responseFormat
}
const response = await client.path('/chat/completions').post({
body: requestBody
})
+43 -14
View File
@@ -3,8 +3,13 @@ import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { connectToGitHubMCP } from './mcp.js'
import { simpleInference, mcpInference, InferenceRequest } from './inference.js'
import { loadContentFromFileOrInput } from './helpers.js'
import { simpleInference, mcpInference } from './inference.js'
import { loadContentFromFileOrInput, buildInferenceRequest } from './helpers.js'
import {
loadPromptFile,
parseTemplateVariables,
isPromptYamlFile
} from './prompt.js'
const RESPONSE_FILE = 'modelResponse.txt'
@@ -15,16 +20,37 @@ const RESPONSE_FILE = 'modelResponse.txt'
*/
export async function run(): Promise<void> {
try {
const prompt = loadContentFromFileOrInput('prompt-file', 'prompt')
const promptFilePath = core.getInput('prompt-file')
const inputVariables = core.getInput('input')
const systemPrompt = loadContentFromFileOrInput(
'system-prompt-file',
'system-prompt',
'You are a helpful assistant'
)
let promptConfig: any = undefined
let systemPrompt: string | undefined = undefined
let prompt: string | undefined = undefined
const modelName: string = core.getInput('model')
const maxTokens: number = parseInt(core.getInput('max-tokens'), 10)
// Check if we're using a prompt YAML file
if (promptFilePath && isPromptYamlFile(promptFilePath)) {
core.info('Using prompt YAML file format')
// Parse template variables
const templateVariables = parseTemplateVariables(inputVariables)
// Load and process prompt file
promptConfig = loadPromptFile(promptFilePath, templateVariables)
} else {
// Use legacy format
core.info('Using legacy prompt format')
prompt = loadContentFromFileOrInput('prompt-file', 'prompt')
systemPrompt = loadContentFromFileOrInput(
'system-prompt-file',
'system-prompt',
'You are a helpful assistant'
)
}
// Get common parameters
const modelName = promptConfig?.model || core.getInput('model')
const maxTokens = parseInt(core.getInput('max-tokens'), 10)
const token = process.env['GITHUB_TOKEN'] || core.getInput('token')
if (token === undefined) {
@@ -32,21 +58,24 @@ export async function run(): Promise<void> {
}
const endpoint = core.getInput('endpoint')
const enableMcp = core.getBooleanInput('enable-github-mcp') || false
const inferenceRequest: InferenceRequest = {
// Build the inference request with pre-processed messages and response format
const inferenceRequest = buildInferenceRequest(
promptConfig,
systemPrompt,
prompt,
modelName,
maxTokens,
endpoint,
token
}
)
const enableMcp = core.getBooleanInput('enable-github-mcp') || false
let modelResponse: string | null = null
if (enableMcp) {
const mcpClient = await connectToGitHubMCP(token)
const mcpClient = await connectToGitHubMCP(inferenceRequest.token)
if (mcpClient) {
modelResponse = await mcpInference(inferenceRequest, mcpClient)
+111
View File
@@ -0,0 +1,111 @@
import * as core from '@actions/core'
import * as fs from 'fs'
import * as yaml from 'js-yaml'
export interface PromptMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export interface PromptConfig {
messages: PromptMessage[]
model?: string
responseFormat?: 'text' | 'json_schema'
jsonSchema?: string
}
export interface TemplateVariables {
[key: string]: string
}
/**
* Parse template variables from YAML input string
*/
export function parseTemplateVariables(input: string): TemplateVariables {
if (!input.trim()) {
return {}
}
try {
const parsed = yaml.load(input) as TemplateVariables
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('Template variables must be a YAML object')
}
return parsed
} catch (error) {
throw new Error(
`Failed to parse template variables: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
/**
* Replace template variables in text using {{variable}} syntax
*/
export function replaceTemplateVariables(
text: string,
variables: TemplateVariables
): string {
return text.replace(/\{\{(\w+)\}\}/g, (match, variableName) => {
if (variableName in variables) {
return variables[variableName]
}
core.warning(
`Template variable '${variableName}' not found in input variables`
)
return match // Return the original placeholder if variable not found
})
}
/**
* Load and parse a prompt YAML file with template variable substitution
*/
export function loadPromptFile(
filePath: string,
templateVariables: TemplateVariables = {}
): PromptConfig {
if (!fs.existsSync(filePath)) {
throw new Error(`Prompt file not found: ${filePath}`)
}
const fileContent = fs.readFileSync(filePath, 'utf-8')
// Apply template variable substitution
const processedContent = replaceTemplateVariables(
fileContent,
templateVariables
)
try {
const config = yaml.load(processedContent) as PromptConfig
if (!config.messages || !Array.isArray(config.messages)) {
throw new Error('Prompt file must contain a "messages" array')
}
// Validate messages
for (const message of config.messages) {
if (!message.role || !message.content) {
throw new Error(
'Each message must have "role" and "content" properties'
)
}
if (!['system', 'user', 'assistant'].includes(message.role)) {
throw new Error(`Invalid message role: ${message.role}`)
}
}
return config
} catch (error) {
throw new Error(
`Failed to parse prompt file: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
/**
* Check if a file is a prompt YAML file based on extension
*/
export function isPromptYamlFile(filePath: string): boolean {
return filePath.endsWith('.prompt.yml') || filePath.endsWith('.prompt.yaml')
}