Support .prompt.yml files
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+3001
-69
File diff suppressed because it is too large
Load Diff
+1
-1
File diff suppressed because one or more lines are too long
Generated
+8
-2
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user