feat: Add system-prompt-file input for file-based system prompts
This enhancement adds the ability to load a system prompt from a file, similar to the existing prompt-file functionality, providing more flexibility when working with complex system prompts. Key changes: - Added new `system-prompt-file` input to action.yml with proper description - Updated main.ts implementation to handle file-based system prompts with: - File existence checking and appropriate error handling - Proper precedence (system-prompt-file takes priority over system-prompt) - Consistent error messages with existing prompt-file implementation Test coverage added: - Basic usage: Test verifies system-prompt-file loads content correctly - Error handling: Test ensures proper errors when system-prompt-file doesn't exist - Precedence: Test confirms system-prompt-file overrides system-prompt when both exist - Integration: Test validates both prompt-file and system-prompt-file work together - Max tokens: Test verifies custom token limits are properly passed to the model - Testing infrastructure: Improved mock implementations that detect unexpected calls Documentation: - Updated README.md with system-prompt-file in inputs table - Added dedicated usage example for system-prompt-file - Fixed formatting in inputs table CI/CD: - Updated workflow to test system-prompt-file functionality with actual file content This feature maintains backward compatibility while adding a useful option for workflows that need to use more complex system prompts stored in files.
This commit is contained in:
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -80,11 +80,16 @@ jobs:
|
||||
- name: Create Prompt File
|
||||
run: echo "hello" > prompt.txt
|
||||
|
||||
- name: Create System Prompt File
|
||||
run:
|
||||
echo "You are a helpful AI assistant for testing." > system-prompt.txt
|
||||
|
||||
- name: Test Local Action with Prompt File
|
||||
id: test-action-prompt-file
|
||||
uses: ./
|
||||
with:
|
||||
prompt-file: prompt.txt
|
||||
system-prompt-file: system-prompt.txt
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
|
||||
34
README.md
34
README.md
@@ -47,6 +47,21 @@ steps:
|
||||
prompt-file: './path/to/prompt.txt'
|
||||
```
|
||||
|
||||
### Using a system prompt file
|
||||
|
||||
In addition to the regular prompt, you can provide a system prompt file instead
|
||||
of an inline system prompt:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: Run AI Inference with System Prompt File
|
||||
id: inference
|
||||
uses: actions/ai-inference@v1
|
||||
with:
|
||||
prompt: 'Hello!'
|
||||
system-prompt-file: './path/to/system-prompt.txt'
|
||||
```
|
||||
|
||||
### Read output from file instead of output
|
||||
|
||||
This can be useful when model response exceeds actions output limit
|
||||
@@ -70,15 +85,16 @@ steps:
|
||||
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 | `""` |
|
||||
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `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 |
|
||||
| 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 | `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 |
|
||||
|
||||
## Outputs
|
||||
|
||||
|
||||
@@ -28,8 +28,13 @@ jest.unstable_mockModule('@azure-rest/ai-inference', () => ({
|
||||
isUnexpected: jest.fn(() => false)
|
||||
}))
|
||||
|
||||
const mockExistsSync = jest.fn().mockReturnValue(true)
|
||||
const mockReadFileSync = jest.fn().mockReturnValue('Hello, AI!')
|
||||
// Default to throwing errors to catch unexpected calls
|
||||
const mockExistsSync = jest.fn().mockImplementation(() => {
|
||||
throw new Error('Unexpected call to existsSync - test should override this implementation')
|
||||
})
|
||||
const mockReadFileSync = jest.fn().mockImplementation(() => {
|
||||
throw new Error('Unexpected call to readFileSync - test should override this implementation')
|
||||
})
|
||||
|
||||
jest.unstable_mockModule('fs', () => ({
|
||||
existsSync: mockExistsSync,
|
||||
@@ -43,12 +48,17 @@ jest.unstable_mockModule('@actions/core', () => core)
|
||||
const { run } = await import('../src/main.js')
|
||||
|
||||
describe('main.ts', () => {
|
||||
// Reset all mocks before each test
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Sets the response output', async () => {
|
||||
// Set the action's inputs as return values from core.getInput().
|
||||
core.getInput.mockImplementation((name) => {
|
||||
if (name === 'prompt') return 'Hello, AI!'
|
||||
if (name === 'system_prompt') return 'You are a test assistant.'
|
||||
if (name === 'model_name') return 'gpt-4o'
|
||||
if (name === 'system-prompt') return 'You are a test assistant.'
|
||||
if (name === 'token') return 'fake-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -67,11 +77,12 @@ describe('main.ts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('Sets a failed status', async () => {
|
||||
it('Sets a failed status when no prompt is set', async () => {
|
||||
// Clear the getInput mock and simulate no prompt or prompt-file input
|
||||
core.getInput.mockImplementation((name) => {
|
||||
if (name === 'prompt') return ''
|
||||
if (name === 'prompt_file') return ''
|
||||
if (name === 'prompt-file') return ''
|
||||
if (name === 'token') return 'fake-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -83,10 +94,21 @@ describe('main.ts', () => {
|
||||
|
||||
it('uses prompt-file', async () => {
|
||||
const promptFile = 'prompt.txt'
|
||||
const promptContent = 'This is a prompt from a file'
|
||||
|
||||
// Set up mock to return specific content for the prompt file
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockImplementation((path, encoding) => {
|
||||
if (path === promptFile && encoding === 'utf-8') {
|
||||
return promptContent
|
||||
}
|
||||
throw new Error(`Unexpected file read: ${path}`)
|
||||
})
|
||||
|
||||
core.getInput.mockImplementation((name) => {
|
||||
if (name === 'prompt-file') return promptFile
|
||||
if (name === 'system-prompt') return 'You are a test assistant.'
|
||||
if (name === 'model-name') return 'gpt-4o'
|
||||
if (name === 'token') return 'fake-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -105,4 +127,286 @@ describe('main.ts', () => {
|
||||
expect.stringContaining('modelResponse.txt')
|
||||
)
|
||||
})
|
||||
|
||||
it('handles non-existent prompt-file with an error', async () => {
|
||||
const promptFile = 'non-existent-prompt.txt'
|
||||
|
||||
// Mock the file not existing
|
||||
mockExistsSync.mockImplementation((path) => {
|
||||
if (path === promptFile) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
core.getInput.mockImplementation((name) => {
|
||||
if (name === 'prompt-file') return promptFile
|
||||
if (name === 'token') return 'fake-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
// Verify that the error was correctly reported
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
`Prompt file not found: ${promptFile}`
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers prompt-file over prompt when both are provided', async () => {
|
||||
const promptFile = 'prompt.txt'
|
||||
const promptFileContent = 'This is a prompt from a file that should be used'
|
||||
const promptString = 'This is a direct prompt that should be ignored'
|
||||
|
||||
// Set up mock to return specific content for the prompt file
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockImplementation((path, encoding) => {
|
||||
if (path === promptFile && encoding === 'utf-8') {
|
||||
return promptFileContent
|
||||
}
|
||||
throw new Error(`Unexpected file read: ${path}`)
|
||||
})
|
||||
|
||||
core.getInput.mockImplementation((name) => {
|
||||
if (name === 'prompt') return promptString
|
||||
if (name === 'prompt-file') return promptFile
|
||||
if (name === 'system-prompt') return 'You are a test assistant.'
|
||||
if (name === 'token') return 'fake-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(promptFile)
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(promptFile, 'utf-8')
|
||||
|
||||
// Check that the post call was made with the prompt from the file, not the input parameter
|
||||
expect(mockPost).toHaveBeenCalledWith({
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: expect.any(String)
|
||||
},
|
||||
{ role: 'user', content: promptFileContent } // Should use the file content, not the string input
|
||||
],
|
||||
max_tokens: expect.any(Number),
|
||||
model: expect.any(String)
|
||||
}
|
||||
})
|
||||
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'response',
|
||||
'Hello, user!'
|
||||
)
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'response-file',
|
||||
expect.stringContaining('modelResponse.txt')
|
||||
)
|
||||
})
|
||||
|
||||
it('uses system-prompt-file', async () => {
|
||||
const systemPromptFile = 'system-prompt.txt'
|
||||
const systemPromptContent = 'You are a specialized system assistant for testing'
|
||||
|
||||
// Set up mock to return specific content for the system prompt file
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockImplementation((path, encoding) => {
|
||||
if (path === systemPromptFile && encoding === 'utf-8') {
|
||||
return systemPromptContent
|
||||
}
|
||||
throw new Error(`Unexpected file read: ${path}`)
|
||||
})
|
||||
|
||||
core.getInput.mockImplementation((name) => {
|
||||
if (name === 'prompt') return 'Hello, AI!'
|
||||
if (name === 'system-prompt-file') return systemPromptFile
|
||||
if (name === 'token') return 'fake-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(systemPromptFile)
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(systemPromptFile, 'utf-8')
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'response',
|
||||
'Hello, user!'
|
||||
)
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'response-file',
|
||||
expect.stringContaining('modelResponse.txt')
|
||||
)
|
||||
})
|
||||
|
||||
it('handles non-existent system-prompt-file with an error', async () => {
|
||||
const systemPromptFile = 'non-existent-system-prompt.txt'
|
||||
|
||||
// Mock the file not existing
|
||||
mockExistsSync.mockImplementation((path) => {
|
||||
if (path === systemPromptFile) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
core.getInput.mockImplementation((name) => {
|
||||
if (name === 'prompt') return 'Hello, AI!'
|
||||
if (name === 'system-prompt-file') return systemPromptFile
|
||||
if (name === 'token') return 'fake-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
// Verify that the error was correctly reported
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
`System prompt file not found: ${systemPromptFile}`
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers system-prompt-file over system-prompt when both are provided', async () => {
|
||||
const systemPromptFile = 'system-prompt.txt'
|
||||
const systemPromptFileContent = 'You are a specialized system assistant from file'
|
||||
const systemPromptString = 'You are a basic system assistant from input parameter'
|
||||
|
||||
// Set up mock to return specific content for the system prompt file
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockImplementation((path, encoding) => {
|
||||
if (path === systemPromptFile && encoding === 'utf-8') {
|
||||
return systemPromptFileContent
|
||||
}
|
||||
throw new Error(`Unexpected file read: ${path}`)
|
||||
})
|
||||
|
||||
core.getInput.mockImplementation((name) => {
|
||||
if (name === 'prompt') return 'Hello, AI!'
|
||||
if (name === 'system-prompt-file') return systemPromptFile
|
||||
if (name === 'system-prompt') return systemPromptString
|
||||
if (name === 'token') return 'fake-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(systemPromptFile)
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(systemPromptFile, 'utf-8')
|
||||
|
||||
// Check that the post call was made with the system prompt from the file, not the input parameter
|
||||
expect(mockPost).toHaveBeenCalledWith({
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPromptFileContent // Should use the file content, not the string input
|
||||
},
|
||||
{ role: 'user', content: 'Hello, AI!' }
|
||||
],
|
||||
max_tokens: expect.any(Number),
|
||||
model: expect.any(String)
|
||||
}
|
||||
})
|
||||
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'response',
|
||||
'Hello, user!'
|
||||
)
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'response-file',
|
||||
expect.stringContaining('modelResponse.txt')
|
||||
)
|
||||
})
|
||||
|
||||
it('uses both prompt-file and system-prompt-file together', async () => {
|
||||
const promptFile = 'prompt.txt'
|
||||
const promptContent = 'This is a prompt from a file'
|
||||
const systemPromptFile = 'system-prompt.txt'
|
||||
const systemPromptContent = 'You are a specialized system assistant from file'
|
||||
|
||||
// Set up mock to return specific content for both files
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockImplementation((path, encoding) => {
|
||||
if (path === promptFile && encoding === 'utf-8') {
|
||||
return promptContent
|
||||
} else if (path === systemPromptFile && encoding === 'utf-8') {
|
||||
return systemPromptContent
|
||||
}
|
||||
throw new Error(`Unexpected file read: ${path}`)
|
||||
})
|
||||
|
||||
core.getInput.mockImplementation((name) => {
|
||||
if (name === 'prompt-file') return promptFile
|
||||
if (name === 'system-prompt-file') return systemPromptFile
|
||||
if (name === 'token') return 'fake-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(promptFile)
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(systemPromptFile)
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(promptFile, 'utf-8')
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(systemPromptFile, 'utf-8')
|
||||
|
||||
// Check that the post call was made with both the prompt and system prompt from files
|
||||
expect(mockPost).toHaveBeenCalledWith({
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPromptContent
|
||||
},
|
||||
{ role: 'user', content: promptContent }
|
||||
],
|
||||
max_tokens: expect.any(Number),
|
||||
model: expect.any(String)
|
||||
}
|
||||
})
|
||||
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'response',
|
||||
'Hello, user!'
|
||||
)
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'response-file',
|
||||
expect.stringContaining('modelResponse.txt')
|
||||
)
|
||||
})
|
||||
|
||||
it('passes custom max-tokens parameter to the model', async () => {
|
||||
const customMaxTokens = 500
|
||||
|
||||
core.getInput.mockImplementation((name) => {
|
||||
if (name === 'prompt') return 'Hello, AI!'
|
||||
if (name === 'system-prompt') return 'You are a test assistant.'
|
||||
if (name === 'token') return 'fake-token'
|
||||
if (name === 'max-tokens') return customMaxTokens.toString()
|
||||
return ''
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
// Check that the post call was made with the correct max_tokens parameter
|
||||
expect(mockPost).toHaveBeenCalledWith({
|
||||
body: {
|
||||
messages: expect.any(Array),
|
||||
max_tokens: customMaxTokens,
|
||||
model: expect.any(String)
|
||||
}
|
||||
})
|
||||
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'response',
|
||||
'Hello, user!'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,10 @@ inputs:
|
||||
description: The system prompt for the model
|
||||
required: false
|
||||
default: 'You are a helpful assistant'
|
||||
system-prompt-file:
|
||||
description: Path to a file containing the system prompt
|
||||
required: false
|
||||
default: ''
|
||||
max-tokens:
|
||||
description: The maximum number of tokens to generate
|
||||
required: false
|
||||
|
||||
17
dist/index.js
generated
vendored
17
dist/index.js
generated
vendored
@@ -33574,7 +33574,22 @@ async function run() {
|
||||
else {
|
||||
throw new Error('prompt is not set');
|
||||
}
|
||||
const systemPrompt = coreExports.getInput('system-prompt');
|
||||
const systemPromptFile = coreExports.getInput('system-prompt-file');
|
||||
const systemPromptString = coreExports.getInput('system-prompt');
|
||||
let systemPrompt;
|
||||
if (systemPromptFile !== undefined && systemPromptFile !== '') {
|
||||
if (!fs.existsSync(systemPromptFile)) {
|
||||
throw new Error(`System prompt file not found: ${systemPromptFile}`);
|
||||
}
|
||||
systemPrompt = fs.readFileSync(systemPromptFile, 'utf-8');
|
||||
}
|
||||
else if (systemPromptString !== undefined && systemPromptString !== '') {
|
||||
systemPrompt = systemPromptString;
|
||||
}
|
||||
else {
|
||||
// Use default system prompt
|
||||
systemPrompt = 'You are a helpful assistant';
|
||||
}
|
||||
const modelName = coreExports.getInput('model');
|
||||
const maxTokens = parseInt(coreExports.getInput('max-tokens'), 10);
|
||||
const token = coreExports.getInput('token') || process.env['GITHUB_TOKEN'];
|
||||
|
||||
2
dist/index.js.map
generated
vendored
2
dist/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
6
package-lock.json
generated
6
package-lock.json
generated
@@ -12,9 +12,9 @@
|
||||
"@actions/core": "^1.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@azure-rest/ai-inference": "*",
|
||||
"@azure/core-auth": "*",
|
||||
"@azure/core-sse": "*",
|
||||
"@azure-rest/ai-inference": "latest",
|
||||
"@azure/core-auth": "latest",
|
||||
"@azure/core-sse": "latest",
|
||||
"@eslint/compat": "^1.2.8",
|
||||
"@github/local-action": "^3.2.0",
|
||||
"@jest/globals": "^29.7.0",
|
||||
|
||||
17
src/main.ts
17
src/main.ts
@@ -29,7 +29,22 @@ export async function run(): Promise<void> {
|
||||
throw new Error('prompt is not set')
|
||||
}
|
||||
|
||||
const systemPrompt: string = core.getInput('system-prompt')
|
||||
const systemPromptFile: string = core.getInput('system-prompt-file')
|
||||
const systemPromptString: string = core.getInput('system-prompt')
|
||||
|
||||
let systemPrompt: string
|
||||
if (systemPromptFile !== undefined && systemPromptFile !== '') {
|
||||
if (!fs.existsSync(systemPromptFile)) {
|
||||
throw new Error(`System prompt file not found: ${systemPromptFile}`)
|
||||
}
|
||||
systemPrompt = fs.readFileSync(systemPromptFile, 'utf-8')
|
||||
} else if (systemPromptString !== undefined && systemPromptString !== '') {
|
||||
systemPrompt = systemPromptString
|
||||
} else {
|
||||
// Use default system prompt
|
||||
systemPrompt = 'You are a helpful assistant'
|
||||
}
|
||||
|
||||
const modelName: string = core.getInput('model')
|
||||
const maxTokens: number = parseInt(core.getInput('max-tokens'), 10)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user