52 Commits

Author SHA1 Message Date
Sean Goedecke
5022b33bc1 Merge pull request #148 from dsanders11/feat/prompt-yaml-model-parameters
feat: support modelParameters in prompt.yaml files
2025-11-24 11:27:47 +11:00
David Sanders
c9e14713bc chore: update dist 2025-11-23 16:19:48 -08:00
David Sanders
39308142df chore: apply code review comment
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-23 16:19:12 -08:00
David Sanders
48f0edec4d feat: support modelParameters in prompt.yaml files 2025-11-23 16:07:11 -08:00
Sean Goedecke
36ea1371dc Merge pull request #136 from dsanders11/fix/template-substition
fix: do template substition after parsing prompt YAML
2025-11-24 10:22:42 +11:00
Sean Goedecke
de16a30c20 Merge branch 'main' into fix/template-substition 2025-11-24 10:21:49 +11:00
Sean Goedecke
dd3dff10ba Merge pull request #147 from srt32/patch-1
Clarify PAT requirement for github-mcp-token
2025-11-24 10:18:50 +11:00
Simon Taranto
4bb01ee5ee Clarify PAT requirement for github-mcp-token
I mistakenly read the description of the mcp-token field to mean I needed a "PAT for MCP" as if there were a PAT permission for MCP. This change clarifies the language.
2025-11-21 13:36:45 -05:00
David Sanders
af1c1c29a3 fix: do template substition after parsing prompt YAML 2025-10-20 21:32:06 -07:00
Sean Goedecke
83bb5ca3e8 Merge pull request #93 from FidelusAleksander/main
docs: update documentation on mcp usage
2025-08-26 18:13:39 +10:00
Aleksander Fidelus
4d2337d006 Merge branch 'actions:main' into main 2025-08-25 11:08:41 +02:00
Yuzuki
7ba7530ad4 Merge pull request #94 from actions/dependabot/github_actions/actions/checkout-5
chore(deps): bump actions/checkout from 4 to 5
2025-08-25 14:00:39 +10:00
Yuzuki
4d7d83c494 Merge branch 'main' into dependabot/github_actions/actions/checkout-5 2025-08-25 13:55:57 +10:00
Sean Goedecke
a1c1182922 Merge pull request #97 from actions/sgoedecke/defensive-parsing
Parse inference response format defensively
2025-08-25 08:47:18 +10:00
Sean Goedecke
dfaa426c29 Parse inference response format defensively 2025-08-22 22:34:18 +00:00
FidelusAleksander
7fa0024f13 docs: run prettier 2025-08-18 14:42:29 +02:00
dependabot[bot]
fc6f9a0800 chore(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-18 03:58:02 +00:00
FidelusAleksander
a1d07305b7 docs: update github-mcp-token description 2025-08-15 08:22:55 +02:00
FidelusAleksander
6e0d8949d8 docs: update documentation on mcp usage 2025-08-15 07:52:22 +02:00
Sean Goedecke
f347eae8eb Merge pull request #91 from JessRudder/secure-tmp-files
Uses tmp library to ensure more secure tmp file creation
2025-08-14 07:15:18 +10:00
Jess Rudder
07fe2f30ad Merge branch 'main' into secure-tmp-files 2025-08-13 14:11:23 -07:00
Jess Rudder
1843310df4 Add license info 2025-08-13 21:07:21 +00:00
Sean Goedecke
c72cb2ef9c Merge pull request #90 from garman/pin-to-sha
Pin two imported actions to a set sha
2025-08-14 06:58:03 +10:00
Jessica Rudder
a2fd223fcf Properly clean up tmp files 2025-08-12 14:31:05 -07:00
Jessica Rudder
3ba8e1b39d Replace manual tmp file creation with tmp library which uses security best practices 2025-08-12 13:49:47 -07:00
Daniel Garman
52e5222a82 pin to a sha 2025-08-12 15:04:16 -04:00
Sean Goedecke
a62dfeda7b Merge pull request #79 from salmanmkc/node24
Node 24
2025-08-11 21:13:39 +10:00
Salman Chishti
48235f7026 Merge branch 'main' into node24 2025-08-11 11:52:36 +01:00
Sean Goedecke
b81b2afb83 Merge pull request #88 from actions/sgoedecke/force-exit-once-inference-finishes
Force exit once inference finishes
2025-08-06 11:01:14 +10:00
Sean Goedecke
9133f81330 package 2025-08-06 00:54:19 +00:00
Sean Goedecke
7923b92ef8 Merge pull request #89 from actions/sgoedecke/ensure-mcp-loops-output-desired-response-format
Ensure MCP loops output the right response format
2025-08-06 10:41:02 +10:00
Sean Goedecke
e44da102bf fixup format parsing 2025-08-05 22:21:28 +00:00
Sean Goedecke
866ae2b5d7 Ensure MCP loops output the right response format
In a tool loop, you can't set response_format because the model needs to
be able to think in plain English. But you still need the final response
to be in the desired format, so we add response_format only on the last
iteration.
2025-08-05 22:06:49 +00:00
Sean Goedecke
4685e0dcd4 Force exit once inference finishes in case we are holding any connections open 2025-08-05 21:42:07 +00:00
Sean Goedecke
0cbed4a106 Merge pull request #86 from actions/sgoedecke/use-openai-sdk
Use the OpenAI SDK
2025-08-05 14:19:47 +10:00
Sean Goedecke
009d5e6e28 Update error 2025-08-05 02:52:11 +00:00
Sean Goedecke
18367df745 Merge branch 'main' into sgoedecke/use-openai-sdk 2025-08-05 02:49:44 +00:00
Sean Goedecke
3c6ec33d64 Merge pull request #85 from actions/sgoedecke/file-inputs
Allow templating variables from files
2025-08-05 12:19:39 +10:00
Sean Goedecke
0347935cb1 licensed 2025-08-05 02:17:25 +00:00
Sean Goedecke
8c9e538880 package 2025-08-05 02:17:03 +00:00
Sean Goedecke
de436346ec Fixup error messages 2025-08-05 02:11:43 +00:00
Sean Goedecke
4b5bb5c538 Use OpenAI SDK to avoid setting apiVersion manually 2025-08-05 02:09:17 +00:00
Sean Goedecke
ea4e7d8bb9 package 2025-08-05 01:52:46 +00:00
Sean Goedecke
aaf9c5af33 Update src/prompt.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-05 11:51:50 +10:00
Sean Goedecke
15868b88f4 Allow templating variables from files 2025-08-05 01:32:32 +00:00
Sean Goedecke
c37f296c98 Merge pull request #84 from actions/sgoedecke/better-error-logging
Log specific error even if it is not an Error
2025-08-05 09:28:24 +10:00
Sean Goedecke
e7ddc840ba npm run package 2025-08-04 23:00:34 +00:00
Sean Goedecke
fa321d4c78 Update src/main.ts
Co-authored-by: Marais Rossouw <me@marais.co>
2025-08-05 08:59:43 +10:00
Sean Goedecke
3b5da63917 update tests 2025-08-04 22:44:17 +00:00
Sean Goedecke
a620b9fa98 Force exit on error 2025-08-04 22:40:30 +00:00
Sean Goedecke
a6d2a86ab3 Log specific error even if it is not an Error 2025-08-04 22:28:10 +00:00
Salman Muin Kayser Chishti
9bbcef8fa4 node 24 2025-08-01 12:13:15 +01:00
24 changed files with 8721 additions and 7447 deletions

View File

@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
id: setup-node

View File

@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
id: setup-node
@@ -54,7 +54,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Test Local Action
id: test-action
@@ -77,7 +77,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Create Prompt File
run: echo "hello" > prompt.txt

View File

@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Initialize CodeQL
id: initialize

View File

@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
id: setup-node
@@ -42,11 +42,11 @@ jobs:
- name: Setup Ruby
id: setup-ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@829114fc20da43a41d27359103ec7a63020954d4
with:
ruby-version: ruby
- uses: licensee/setup-licensed@v1.3.2
- uses: licensee/setup-licensed@0d52e575b3258417672be0dff2f115d7db8771d8
with:
version: 4.x
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0

View File

@@ -0,0 +1,32 @@
---
name: "@types/tmp"
version: 0.2.6
type: npm
summary: TypeScript definitions for tmp
homepage: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/tmp
license: mit
licenses:
- sources: LICENSE
text: |2
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
notices: []

View File

@@ -0,0 +1,212 @@
---
name: openai
version: 5.11.0
type: npm
summary: The official TypeScript library for the OpenAI API
homepage:
license: apache-2.0
licenses:
- sources: LICENSE
text: |2
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 OpenAI
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
notices: []

32
.licenses/npm/tmp.dep.yml Normal file
View File

@@ -0,0 +1,32 @@
---
name: tmp
version: 0.2.5
type: npm
summary: Temporary file and directory creator
homepage: http://github.com/raszi/node-tmp
license: mit
licenses:
- sources: LICENSE
text: |
The MIT License (MIT)
Copyright (c) 2014 KARASZI István
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
notices: []

View File

@@ -1 +1 @@
20.9.0
24.4.0

View File

@@ -65,6 +65,9 @@ steps:
var3: |
Lorem Ipsum
Hello World
file_input: |
var4: ./path/to/long-text.txt
var5: ./path/to/config.json
```
#### Simple prompt.yml example
@@ -116,7 +119,9 @@ jsonSchema: |-
```
Variables in prompt.yml files are templated using `{{variable}}` format and are
supplied via the `input` parameter in YAML format.
supplied via the `input` parameter in YAML format. Additionally, you can
provide file-based variables via `file_input`, where each key maps to a file
path.
### Using a system prompt file
@@ -157,6 +162,9 @@ This action now supports **read-only** integration with the GitHub-hosted Model
Context Protocol (MCP) server, which provides access to GitHub tools like
repository management, issue tracking, and pull request operations.
> [!NOTE]
> The GitHub MCP integration requires a Personal Access Token (PAT) and cannot use the built-in `GITHUB_TOKEN`.
```yaml
steps:
- name: AI Inference with GitHub Tools
@@ -197,13 +205,14 @@ the action:
| `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) | `""` |
| `file_input` | Template variables in YAML where values are file paths. The file contents are read and used for templating | `""` |
| `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` |
| `github-mcp-token` | Token to use for GitHub MCP server (defaults to the main token if not specified). Use a separate PAT for tighter security | `""` |
| `github-mcp-token` | Token to use for GitHub MCP server (defaults to the main token if not specified). This must be a PAT in order for MCP to work | `""` |
## Outputs

View File

@@ -106,6 +106,8 @@ describe('helpers.ts - inference request building', () => {
undefined,
undefined,
'gpt-4',
undefined,
undefined,
100,
'https://api.test.com',
'test-token',
@@ -117,6 +119,8 @@ describe('helpers.ts - inference request building', () => {
{role: 'user', content: 'User message'},
],
modelName: 'gpt-4',
temperature: undefined,
topP: undefined,
maxTokens: 100,
endpoint: 'https://api.test.com',
token: 'test-token',
@@ -136,6 +140,8 @@ describe('helpers.ts - inference request building', () => {
'System prompt',
'User prompt',
'gpt-4',
undefined,
undefined,
100,
'https://api.test.com',
'test-token',
@@ -147,6 +153,8 @@ describe('helpers.ts - inference request building', () => {
{role: 'user', content: 'User prompt'},
],
modelName: 'gpt-4',
temperature: undefined,
topP: undefined,
maxTokens: 100,
endpoint: 'https://api.test.com',
token: 'test-token',

View File

@@ -2,17 +2,15 @@ import {vi, type MockedFunction, beforeEach, expect, describe, it} from 'vitest'
import * as core from '../__fixtures__/core.js'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockPost = vi.fn() as MockedFunction<any>
const mockPath = vi.fn(() => ({post: mockPost}))
const mockClient = vi.fn(() => ({path: mockPath}))
vi.mock('@azure-rest/ai-inference', () => ({
default: mockClient,
isUnexpected: vi.fn(() => false),
const mockCreate = vi.fn() as MockedFunction<any>
const mockCompletions = {create: mockCreate}
const mockChat = {completions: mockCompletions}
const mockOpenAIClient = vi.fn(() => ({
chat: mockChat,
}))
vi.mock('@azure/core-auth', () => ({
AzureKeyCredential: vi.fn(),
vi.mock('openai', () => ({
default: mockOpenAIClient,
}))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -29,8 +27,8 @@ const {simpleInference, mcpInference} = await import('../src/inference.js')
describe('inference.ts', () => {
const mockRequest = {
messages: [
{role: 'system', content: 'You are a test assistant'},
{role: 'user', content: 'Hello, AI!'},
{role: 'system' as const, content: 'You are a test assistant'},
{role: 'user' as const, content: 'Hello, AI!'},
],
modelName: 'gpt-4',
maxTokens: 100,
@@ -45,18 +43,16 @@ describe('inference.ts', () => {
describe('simpleInference', () => {
it('performs simple inference without tools', async () => {
const mockResponse = {
body: {
choices: [
{
message: {
content: 'Hello, user!',
},
choices: [
{
message: {
content: 'Hello, user!',
},
],
},
},
],
}
mockPost.mockResolvedValue(mockResponse)
mockCreate.mockResolvedValue(mockResponse)
const result = await simpleInference(mockRequest)
@@ -65,44 +61,83 @@ describe('inference.ts', () => {
expect(core.info).toHaveBeenCalledWith('Model response: Hello, user!')
// Verify the request structure
expect(mockPost).toHaveBeenCalledWith({
body: {
messages: [
{
role: 'system',
content: 'You are a test assistant',
},
{
role: 'user',
content: 'Hello, AI!',
},
],
max_tokens: 100,
model: 'gpt-4',
},
expect(mockCreate).toHaveBeenCalledWith({
messages: [
{
role: 'system',
content: 'You are a test assistant',
},
{
role: 'user',
content: 'Hello, AI!',
},
],
max_tokens: 100,
model: 'gpt-4',
})
})
it('handles null response content', async () => {
const mockResponse = {
body: {
choices: [
{
message: {
content: null,
},
choices: [
{
message: {
content: null,
},
],
},
},
],
}
mockPost.mockResolvedValue(mockResponse)
mockCreate.mockResolvedValue(mockResponse)
const result = await simpleInference(mockRequest)
expect(result).toBeNull()
expect(core.info).toHaveBeenCalledWith('Model response: No response content')
})
it('includes response format when specified', async () => {
const requestWithResponseFormat = {
...mockRequest,
responseFormat: {
type: 'json_schema' as const,
json_schema: {type: 'object'},
},
}
const mockResponse = {
choices: [
{
message: {
content: '{"result": "success"}',
},
},
],
}
mockCreate.mockResolvedValue(mockResponse)
const result = await simpleInference(requestWithResponseFormat)
expect(result).toBe('{"result": "success"}')
// Verify response format was included in the request
expect(mockCreate).toHaveBeenCalledWith({
messages: [
{
role: 'system',
content: 'You are a test assistant',
},
{
role: 'user',
content: 'Hello, AI!',
},
],
max_tokens: 100,
model: 'gpt-4',
response_format: requestWithResponseFormat.responseFormat,
})
})
})
describe('mcpInference', () => {
@@ -123,19 +158,17 @@ describe('inference.ts', () => {
it('performs inference without tool calls', async () => {
const mockResponse = {
body: {
choices: [
{
message: {
content: 'Hello, user!',
tool_calls: null,
},
choices: [
{
message: {
content: 'Hello, user!',
tool_calls: null,
},
],
},
},
],
}
mockPost.mockResolvedValue(mockResponse)
mockCreate.mockResolvedValue(mockResponse)
const result = await mcpInference(mockRequest, mockMcpClient)
@@ -146,12 +179,13 @@ describe('inference.ts', () => {
// The MCP inference loop will always add the assistant message, even when there are no tool calls
// So we don't check the exact messages, just that tools were included
expect(mockPost).toHaveBeenCalledTimes(1)
expect(mockCreate).toHaveBeenCalledTimes(1)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs = mockPost.mock.calls[0][0] as any
expect(callArgs.body.tools).toEqual(mockMcpClient.tools)
expect(callArgs.body.model).toBe('gpt-4')
expect(callArgs.body.max_tokens).toBe(100)
const callArgs = mockCreate.mock.calls[0][0] as any
expect(callArgs.tools).toEqual(mockMcpClient.tools)
expect(callArgs.response_format).toBeUndefined()
expect(callArgs.model).toBe('gpt-4')
expect(callArgs.max_tokens).toBe(100)
})
it('executes tool calls and continues conversation', async () => {
@@ -176,33 +210,29 @@ describe('inference.ts', () => {
// First response with tool calls
const firstResponse = {
body: {
choices: [
{
message: {
content: 'I need to use a tool.',
tool_calls: toolCalls,
},
choices: [
{
message: {
content: 'I need to use a tool.',
tool_calls: toolCalls,
},
],
},
},
],
}
// Second response after tool execution
const secondResponse = {
body: {
choices: [
{
message: {
content: 'Here is the final answer.',
tool_calls: null,
},
choices: [
{
message: {
content: 'Here is the final answer.',
tool_calls: null,
},
],
},
},
],
}
mockPost.mockResolvedValueOnce(firstResponse).mockResolvedValueOnce(secondResponse)
mockCreate.mockResolvedValueOnce(firstResponse).mockResolvedValueOnce(secondResponse)
mockExecuteToolCalls.mockResolvedValue(toolResults)
@@ -210,15 +240,15 @@ describe('inference.ts', () => {
expect(result).toBe('Here is the final answer.')
expect(mockExecuteToolCalls).toHaveBeenCalledWith(mockMcpClient.client, toolCalls)
expect(mockPost).toHaveBeenCalledTimes(2)
expect(mockCreate).toHaveBeenCalledTimes(2)
// Verify the second call includes the conversation history
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const secondCall = mockPost.mock.calls[1][0] as any
expect(secondCall.body.messages).toHaveLength(5) // system, user, assistant, tool, assistant
expect(secondCall.body.messages[2].role).toBe('assistant')
expect(secondCall.body.messages[2].tool_calls).toEqual(toolCalls)
expect(secondCall.body.messages[3]).toEqual(toolResults[0])
const secondCall = mockCreate.mock.calls[1][0] as any
expect(secondCall.messages).toHaveLength(5) // system, user, assistant, tool, assistant
expect(secondCall.messages[2].role).toBe('assistant')
expect(secondCall.messages[2].tool_calls).toEqual(toolCalls)
expect(secondCall.messages[3]).toEqual(toolResults[0])
})
it('handles maximum iteration limit', async () => {
@@ -243,43 +273,39 @@ describe('inference.ts', () => {
// Always respond with tool calls to trigger infinite loop
const responseWithToolCalls = {
body: {
choices: [
{
message: {
content: 'Using tool again.',
tool_calls: toolCalls,
},
choices: [
{
message: {
content: 'Using tool again.',
tool_calls: toolCalls,
},
],
},
},
],
}
mockPost.mockResolvedValue(responseWithToolCalls)
mockCreate.mockResolvedValue(responseWithToolCalls)
mockExecuteToolCalls.mockResolvedValue(toolResults)
const result = await mcpInference(mockRequest, mockMcpClient)
expect(mockPost).toHaveBeenCalledTimes(5) // Max iterations reached
expect(mockCreate).toHaveBeenCalledTimes(5) // Max iterations reached
expect(core.warning).toHaveBeenCalledWith('GitHub MCP inference loop exceeded maximum iterations (5)')
expect(result).toBe('Using tool again.') // Last assistant message
})
it('handles empty tool calls array', async () => {
const mockResponse = {
body: {
choices: [
{
message: {
content: 'Hello, user!',
tool_calls: [],
},
choices: [
{
message: {
content: 'Hello, user!',
tool_calls: [],
},
],
},
},
],
}
mockPost.mockResolvedValue(mockResponse)
mockCreate.mockResolvedValue(mockResponse)
const result = await mcpInference(mockRequest, mockMcpClient)
@@ -297,32 +323,28 @@ describe('inference.ts', () => {
]
const firstResponse = {
body: {
choices: [
{
message: {
content: 'First message',
tool_calls: toolCalls,
},
choices: [
{
message: {
content: 'First message',
tool_calls: toolCalls,
},
],
},
},
],
}
const secondResponse = {
body: {
choices: [
{
message: {
content: 'Second message',
tool_calls: toolCalls,
},
choices: [
{
message: {
content: 'Second message',
tool_calls: toolCalls,
},
],
},
},
],
}
mockPost.mockResolvedValueOnce(firstResponse).mockResolvedValue(secondResponse)
mockCreate.mockResolvedValueOnce(firstResponse).mockResolvedValue(secondResponse)
mockExecuteToolCalls.mockResolvedValue([
{
@@ -337,5 +359,191 @@ describe('inference.ts', () => {
expect(result).toBe('Second message')
})
it('makes additional loop with response format when no tool calls are made', async () => {
const requestWithResponseFormat = {
...mockRequest,
responseFormat: {
type: 'json_schema' as const,
json_schema: {type: 'object'},
},
}
// First response without tool calls
const firstResponse = {
choices: [
{
message: {
content: 'First response',
tool_calls: null,
},
},
],
}
// Second response with response format applied
const secondResponse = {
choices: [
{
message: {
content: '{"result": "formatted response"}',
tool_calls: null,
},
},
],
}
mockCreate.mockResolvedValueOnce(firstResponse).mockResolvedValueOnce(secondResponse)
const result = await mcpInference(requestWithResponseFormat, mockMcpClient)
expect(result).toBe('{"result": "formatted response"}')
expect(mockCreate).toHaveBeenCalledTimes(2)
expect(core.info).toHaveBeenCalledWith('Making one more MCP loop with the requested response format...')
// First call should have tools but no response format
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const firstCall = mockCreate.mock.calls[0][0] as any
expect(firstCall.tools).toEqual(mockMcpClient.tools)
expect(firstCall.response_format).toBeUndefined()
// Second call should have response format but no tools
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const secondCall = mockCreate.mock.calls[1][0] as any
expect(secondCall.tools).toBeUndefined()
expect(secondCall.response_format).toEqual(requestWithResponseFormat.responseFormat)
// Second call should include the user message requesting JSON format
expect(secondCall.messages).toHaveLength(5) // system, user, assistant, user, assistant
expect(secondCall.messages[3].role).toBe('user')
expect(secondCall.messages[3].content).toContain('Please provide your response in the exact')
})
it('uses response format only on final iteration after tool calls', async () => {
const requestWithResponseFormat = {
...mockRequest,
responseFormat: {
type: 'json_schema' as const,
json_schema: {type: 'object'},
},
}
const toolCalls = [
{
id: 'call-123',
function: {
name: 'test-tool',
arguments: '{"param": "value"}',
},
},
]
const toolResults = [
{
tool_call_id: 'call-123',
role: 'tool',
name: 'test-tool',
content: 'Tool result',
},
]
// First response with tool calls
const firstResponse = {
choices: [
{
message: {
content: 'Using tool',
tool_calls: toolCalls,
},
},
],
}
// Second response without tool calls, but should trigger final message loop
const secondResponse = {
choices: [
{
message: {
content: 'Intermediate result',
tool_calls: null,
},
},
],
}
// Third response with response format
const thirdResponse = {
choices: [
{
message: {
content: '{"final": "result"}',
tool_calls: null,
},
},
],
}
mockCreate
.mockResolvedValueOnce(firstResponse)
.mockResolvedValueOnce(secondResponse)
.mockResolvedValueOnce(thirdResponse)
mockExecuteToolCalls.mockResolvedValue(toolResults)
const result = await mcpInference(requestWithResponseFormat, mockMcpClient)
expect(result).toBe('{"final": "result"}')
expect(mockCreate).toHaveBeenCalledTimes(3)
// First call: tools but no response format
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const firstCall = mockCreate.mock.calls[0][0] as any
expect(firstCall.tools).toEqual(mockMcpClient.tools)
expect(firstCall.response_format).toBeUndefined()
// Second call: tools but no response format (after tool execution)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const secondCall = mockCreate.mock.calls[1][0] as any
expect(secondCall.tools).toEqual(mockMcpClient.tools)
expect(secondCall.response_format).toBeUndefined()
// Third call: response format but no tools (final message)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const thirdCall = mockCreate.mock.calls[2][0] as any
expect(thirdCall.tools).toBeUndefined()
expect(thirdCall.response_format).toEqual(requestWithResponseFormat.responseFormat)
})
it('returns immediately when response format is set and finalMessage is already true', async () => {
const requestWithResponseFormat = {
...mockRequest,
responseFormat: {
type: 'json_schema' as const,
json_schema: {type: 'object'},
},
}
// Response without tool calls on what would be the final message iteration
const mockResponse = {
choices: [
{
message: {
content: '{"immediate": "result"}',
tool_calls: null,
},
},
],
}
mockCreate.mockResolvedValue(mockResponse)
// We need to test a scenario where finalMessage would already be true
// This happens when we're already in the final iteration
const result = await mcpInference(requestWithResponseFormat, mockMcpClient)
// The function should make two calls: one normal, then one with response format
expect(mockCreate).toHaveBeenCalledTimes(2)
expect(result).toBe('{"immediate": "result"}')
})
})
})

View File

@@ -34,6 +34,12 @@ vi.mock('../src/mcp.js', () => ({
vi.mock('@actions/core', () => core)
// Mock process.exit to prevent it from actually exiting during tests
const mockProcessExit = vi.spyOn(process, 'exit').mockImplementation(() => {
// Prevent actual exit, but don't throw - just return
return undefined as never
})
// The module being tested should be imported dynamically. This ensures that the
// mocks are used in place of any actual dependencies.
const {run} = await import('../src/main.js')
@@ -41,6 +47,7 @@ const {run} = await import('../src/main.js')
describe('main.ts - prompt.yml integration', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProcessExit.mockClear()
// Mock environment variables
process.env['GITHUB_TOKEN'] = 'test-token'
@@ -103,8 +110,12 @@ model: openai/gpt-4o
}
})
// Expect the run function to complete successfully
await run()
// Verify process.exit was called with code 0 (success)
expect(mockProcessExit).toHaveBeenCalledWith(0)
// Verify simpleInference was called with the correct message structure
expect(mockSimpleInference).toHaveBeenCalledWith(
expect.objectContaining({
@@ -130,6 +141,52 @@ model: openai/gpt-4o
expect(core.setOutput).toHaveBeenCalledWith('response-file', expect.any(String))
})
it('supports file_input variables to load file contents', async () => {
mockExistsSync.mockReturnValue(true)
// First call: reading the prompt file. Second call: reading file_input referenced file contents.
const externalFilePath = 'vars.txt'
mockReadFileSync.mockImplementation((path: string) => {
if (path === 'test.prompt.yml') {
return `messages:\n - role: user\n content: 'Here is the data: {{blob}}'\nmodel: openai/gpt-4o\n`
}
if (path === externalFilePath) {
return 'FILE_CONTENTS'
}
return ''
})
core.getInput.mockImplementation((name: string) => {
switch (name) {
case 'prompt-file':
return 'test.prompt.yml'
case 'file_input':
return `blob: ${externalFilePath}`
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()
expect(mockSimpleInference).toHaveBeenCalledWith(
expect.objectContaining({
messages: [{role: 'user', content: 'Here is the data: FILE_CONTENTS'}],
}),
)
// Verify process.exit was called with code 0 (success)
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('should fall back to legacy format when not using prompt YAML', async () => {
mockExistsSync.mockReturnValue(false)
core.getInput.mockImplementation((name: string) => {
@@ -172,5 +229,8 @@ model: openai/gpt-4o
token: 'test-token',
}),
)
// Verify process.exit was called with code 0 (success)
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
})

View File

@@ -66,7 +66,7 @@ function mockInputs(inputs: Record<string, string> = {}): void {
*/
function verifyStandardResponse(): void {
expect(core.setOutput).toHaveBeenNthCalledWith(1, 'response', 'Hello, user!')
expect(core.setOutput).toHaveBeenNthCalledWith(2, 'response-file', expect.stringContaining('modelResponse.txt'))
expect(core.setOutput).toHaveBeenNthCalledWith(2, 'response-file', expect.stringContaining('modelResponse-'))
}
vi.mock('fs', () => ({
@@ -75,6 +75,19 @@ vi.mock('fs', () => ({
writeFileSync: mockWriteFileSync,
}))
// Mocks for tmp module to control temporary file creation and cleanup
const mockRemoveCallback = vi.fn()
const mockFileSync = vi.fn().mockReturnValue({
name: '/secure/temp/dir/modelResponse-abc123.txt',
removeCallback: mockRemoveCallback,
})
const mockSetGracefulCleanup = vi.fn()
vi.mock('tmp', () => ({
fileSync: mockFileSync,
setGracefulCleanup: mockSetGracefulCleanup,
}))
// Mock MCP and inference modules
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockConnectToGitHubMCP = vi.fn() as MockedFunction<any>
@@ -94,6 +107,12 @@ vi.mock('../src/inference.js', () => ({
vi.mock('@actions/core', () => core)
// Mock process.exit to prevent it from actually exiting during tests
const mockProcessExit = vi.spyOn(process, 'exit').mockImplementation(() => {
// Prevent actual exit, but don't throw - just return
return undefined as never
})
// The module being tested should be imported dynamically. This ensures that the
// mocks are used in place of any actual dependencies.
const {run} = await import('../src/main.js')
@@ -102,6 +121,7 @@ describe('main.ts', () => {
// Reset all mocks before each test
beforeEach(() => {
vi.clearAllMocks()
mockProcessExit.mockClear()
// Remove any existing GITHUB_TOKEN
delete process.env.GITHUB_TOKEN
@@ -121,6 +141,7 @@ describe('main.ts', () => {
expect(core.setOutput).toHaveBeenCalled()
verifyStandardResponse()
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('Sets a failed status when no prompt is set', async () => {
@@ -131,7 +152,8 @@ describe('main.ts', () => {
await run()
expect(core.setFailed).toHaveBeenNthCalledWith(1, 'Neither prompt-file nor prompt was set')
expect(core.setFailed).toHaveBeenCalledWith('Neither prompt-file nor prompt was set')
expect(mockProcessExit).toHaveBeenCalledWith(1)
})
it('uses simple inference when MCP is disabled', async () => {
@@ -157,6 +179,7 @@ describe('main.ts', () => {
expect(mockConnectToGitHubMCP).not.toHaveBeenCalled()
expect(mockMcpInference).not.toHaveBeenCalled()
verifyStandardResponse()
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('uses MCP inference when enabled and connection succeeds', async () => {
@@ -189,6 +212,7 @@ describe('main.ts', () => {
)
expect(mockSimpleInference).not.toHaveBeenCalled()
verifyStandardResponse()
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('falls back to simple inference when MCP connection fails', async () => {
@@ -207,6 +231,7 @@ describe('main.ts', () => {
expect(mockMcpInference).not.toHaveBeenCalled()
expect(core.warning).toHaveBeenCalledWith('MCP connection failed, falling back to simple inference')
verifyStandardResponse()
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('properly integrates with loadContentFromFileOrInput', async () => {
@@ -240,6 +265,7 @@ describe('main.ts', () => {
responseFormat: undefined,
})
verifyStandardResponse()
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('handles non-existent prompt-file with an error', async () => {
@@ -254,5 +280,45 @@ describe('main.ts', () => {
await run()
expect(core.setFailed).toHaveBeenCalledWith(`File for prompt-file was not found: ${promptFile}`)
expect(mockProcessExit).toHaveBeenCalledWith(1)
})
it('creates secure temporary files with proper cleanup', async () => {
mockInputs({
prompt: 'Test prompt',
'system-prompt': 'You are a test assistant.',
})
await run()
expect(mockSetGracefulCleanup).toHaveBeenCalledOnce()
expect(mockFileSync).toHaveBeenCalledWith({
prefix: 'modelResponse-',
postfix: '.txt',
})
expect(core.setOutput).toHaveBeenNthCalledWith(2, 'response-file', '/secure/temp/dir/modelResponse-abc123.txt')
expect(mockWriteFileSync).toHaveBeenCalledWith('/secure/temp/dir/modelResponse-abc123.txt', 'Hello, user!', 'utf-8')
expect(mockRemoveCallback).toHaveBeenCalledOnce()
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
it('handles cleanup errors gracefully', async () => {
mockRemoveCallback.mockImplementationOnce(() => {
throw new Error('Cleanup failed')
})
mockInputs({
prompt: 'Test prompt',
'system-prompt': 'You are a test assistant.',
})
await run()
expect(mockRemoveCallback).toHaveBeenCalledOnce()
expect(core.warning).toHaveBeenCalledWith('Failed to cleanup temporary file: Error: Cleanup failed')
expect(mockProcessExit).toHaveBeenCalledWith(0)
})
})

View File

@@ -1,7 +1,13 @@
import {describe, it, expect} from 'vitest'
import * as path from 'path'
import {fileURLToPath} from 'url'
import {parseTemplateVariables, replaceTemplateVariables, loadPromptFile, isPromptYamlFile} from '../src/prompt'
import {
parseTemplateVariables,
replaceTemplateVariables,
loadPromptFile,
isPromptYamlFile,
parseFileTemplateVariables,
} from '../src/prompt'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -10,8 +16,8 @@ describe('prompt.ts', () => {
describe('parseTemplateVariables', () => {
it('should parse simple YAML variables', () => {
const input = `
a: hello
b: world
a: hello
b: world
`
const result = parseTemplateVariables(input)
expect(result).toEqual({a: 'hello', b: 'world'})
@@ -19,10 +25,10 @@ b: world
it('should parse multiline variables', () => {
const input = `
var1: hello
var2: |
This is a
multiline string
var1: hello
var2: |
This is a
multiline string
`
const result = parseTemplateVariables(input)
expect(result.var1).toBe('hello')
@@ -117,4 +123,17 @@ var2: |
expect(() => loadPromptFile('non-existent.prompt.yml')).toThrow('Prompt file not found')
})
})
describe('parseFileTemplateVariables', () => {
it('reads file contents for variables', () => {
const configPath = path.join(__dirname, '../__fixtures__/prompts/json-schema.prompt.yml')
const data = parseFileTemplateVariables(`sample: ${configPath}`)
expect(data.sample).toContain('messages:')
expect(data.sample).toContain('responseFormat:')
})
it('errors on missing files', () => {
expect(() => parseFileTemplateVariables('x: ./does-not-exist.txt')).toThrow('was not found')
})
})
})

View File

@@ -22,6 +22,10 @@ inputs:
description: Template variables in YAML format for .prompt.yml files
required: false
default: ''
file_input:
description: Template variables in YAML format mapping variable names to file paths. The file contents will be used for templating.
required: false
default: ''
model:
description: The model to use
required: false
@@ -51,7 +55,7 @@ inputs:
required: false
default: 'false'
github-mcp-token:
description: The token to use for GitHub MCP server (defaults to GITHUB_TOKEN if not specified)
description: The token to use for GitHub MCP server (defaults to the main token if not specified). This must be a PAT for MCP to work.
required: false
default: ''
@@ -63,5 +67,5 @@ outputs:
description: The file path where the response is saved
runs:
using: node20
using: node24
main: dist/index.js

14750
dist/index.js generated vendored

File diff suppressed because it is too large Load Diff

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

114
package-lock.json generated
View File

@@ -11,14 +11,13 @@
"dependencies": {
"@actions/core": "^1.11.1",
"@modelcontextprotocol/sdk": "^1.15.1",
"@rollup/rollup-linux-x64-gnu": "*",
"@types/tmp": "^0.2.6",
"js-yaml": "^4.1.0",
"pkce-challenge": "^5.0.0"
"openai": "^5.11.0",
"pkce-challenge": "^5.0.0",
"tmp": "^0.2.4"
},
"devDependencies": {
"@azure-rest/ai-inference": "*",
"@azure/core-auth": "*",
"@azure/core-sse": "*",
"@eslint/compat": "^1.3.0",
"@github/local-action": "^5.1.0",
"@github/prettier-config": "^0.0.6",
@@ -27,7 +26,7 @@
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.15.31",
"@types/node": "^24.1.0",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.32.1",
"eslint": "^9.29.0",
@@ -42,7 +41,7 @@
"vitest": "^3"
},
"engines": {
"node": ">=20"
"node": ">=24"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "*"
@@ -519,44 +518,6 @@
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
"license": "MIT"
},
"node_modules/@azure-rest/ai-inference": {
"version": "1.0.0-beta.6",
"resolved": "https://registry.npmjs.org/@azure-rest/ai-inference/-/ai-inference-1.0.0-beta.6.tgz",
"integrity": "sha512-j5FrJDTHu2P2+zwFVe5j2edasOIhqkFj+VkDjbhGkQuOoIAByF0egRkgs0G1k03HyJ7bOOT9BkRF7MIgr/afhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure-rest/core-client": "^2.1.0",
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.9.0",
"@azure/core-lro": "^2.7.2",
"@azure/core-rest-pipeline": "^1.18.2",
"@azure/core-tracing": "^1.2.0",
"@azure/logger": "^1.1.4",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure-rest/core-client": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.4.0.tgz",
"integrity": "sha512-CjMFBcmnt0YNdRcxSSoZbtZNXudLlicdml7UrPsV03nHiWB+Bq5cu5ctieyaCuRtU7jm7+SOFtiE/g4pBFPKKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
"@azure/core-auth": "^1.3.0",
"@azure/core-rest-pipeline": "^1.5.0",
"@azure/core-tracing": "^1.0.1",
"@typespec/ts-http-runtime": "^0.2.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
@@ -667,19 +628,6 @@
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-sse": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@azure/core-sse/-/core-sse-2.3.0.tgz",
"integrity": "sha512-jKhPpdDbVS5GlpadSKIC7V6Q4P2vEcwXi1c4CLTXs01Q/PAITES9v5J/S73+RtCMqQpsX0jGa2yPWwXi9JzdgA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-tracing": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.2.0.tgz",
@@ -2530,13 +2478,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.15.31",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.31.tgz",
"integrity": "sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==",
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~7.8.0"
}
},
"node_modules/@types/resolve": {
@@ -2546,6 +2494,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/tmp": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz",
"integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.34.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.0.tgz",
@@ -7035,6 +6989,27 @@
"wrappy": "1"
}
},
"node_modules/openai": {
"version": "5.11.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.11.0.tgz",
"integrity": "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -8976,6 +8951,15 @@
"node": ">=14.0.0"
}
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -9240,9 +9224,9 @@
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"dev": true,
"license": "MIT"
},

View File

@@ -6,7 +6,7 @@
".": "./dist/index.js"
},
"engines": {
"node": ">=20"
"node": ">=24"
},
"scripts": {
"bundle": "npm run format:write && npm run package",
@@ -25,13 +25,13 @@
"dependencies": {
"@actions/core": "^1.11.1",
"@modelcontextprotocol/sdk": "^1.15.1",
"@types/tmp": "^0.2.6",
"js-yaml": "^4.1.0",
"pkce-challenge": "^5.0.0"
"openai": "^5.11.0",
"pkce-challenge": "^5.0.0",
"tmp": "^0.2.4"
},
"devDependencies": {
"@azure-rest/ai-inference": "latest",
"@azure/core-auth": "latest",
"@azure/core-sse": "latest",
"@eslint/compat": "^1.3.0",
"@github/local-action": "^5.1.0",
"@github/prettier-config": "^0.0.6",
@@ -40,7 +40,7 @@
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.15.31",
"@types/node": "^24.1.0",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.32.1",
"eslint": "^9.29.0",

View File

@@ -1,5 +1,4 @@
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'
@@ -29,36 +28,6 @@ export function loadContentFromFileOrInput(filePathInput: string, contentInput:
}
}
/**
* Helper function to handle unexpected responses from AI service
* @param response - The response object from the AI service
* @throws Error with appropriate error message based on response content
*/
export function handleUnexpectedResponse(response: GetChatCompletionsDefaultResponse): never {
// Extract x-ms-error-code from headers if available
const errorCode = response.headers['x-ms-error-code']
const errorCodeMsg = errorCode ? ` (error code: ${errorCode})` : ''
// Check if response body exists and contains error details
if (response.body && response.body.error) {
throw response.body.error
}
// Handle case where response body is missing
if (!response.body) {
throw new Error(
`Failed to get response from AI service (status: ${response.status})${errorCodeMsg}. ` +
'Please check network connection and endpoint configuration.',
)
}
// Handle other error cases
throw new Error(
`AI service returned error response (status: ${response.status})${errorCodeMsg}: ` +
(typeof response.body === 'string' ? response.body : JSON.stringify(response.body)),
)
}
/**
* Build messages array from either prompt config or legacy format
*/
@@ -66,11 +35,11 @@ export function buildMessages(
promptConfig?: PromptConfig,
systemPrompt?: string,
prompt?: string,
): Array<{role: string; content: string}> {
): Array<{role: 'system' | 'user' | 'assistant' | 'tool'; content: string}> {
if (promptConfig?.messages && promptConfig.messages.length > 0) {
// Use new message format
return promptConfig.messages.map(msg => ({
role: msg.role,
role: msg.role as 'system' | 'user' | 'assistant' | 'tool',
content: msg.content,
}))
} else {
@@ -113,6 +82,8 @@ export function buildInferenceRequest(
systemPrompt: string | undefined,
prompt: string | undefined,
modelName: string,
temperature: number | undefined,
topP: number | undefined,
maxTokens: number,
endpoint: string,
token: string,
@@ -123,6 +94,8 @@ export function buildInferenceRequest(
return {
messages,
modelName,
temperature,
topP,
maxTokens,
endpoint,
token,

View File

@@ -1,29 +1,22 @@
import * as core from '@actions/core'
import ModelClient, {isUnexpected} from '@azure-rest/ai-inference'
import {AzureKeyCredential} from '@azure/core-auth'
import {GitHubMCPClient, executeToolCalls, MCPTool, ToolCall} from './mcp.js'
import {handleUnexpectedResponse} from './helpers.js'
import OpenAI from 'openai'
import {GitHubMCPClient, executeToolCalls, ToolCall} from './mcp.js'
interface ChatMessage {
role: string
role: 'system' | 'user' | 'assistant' | 'tool'
content: string | null
tool_calls?: ToolCall[]
}
interface ChatCompletionsRequestBody {
messages: ChatMessage[]
max_tokens: number
model: string
response_format?: {type: 'json_schema'; json_schema: unknown}
tools?: MCPTool[]
tool_call_id?: string
}
export interface InferenceRequest {
messages: Array<{role: string; content: string}>
messages: Array<{role: 'system' | 'user' | 'assistant' | 'tool'; content: string}>
modelName: string
maxTokens: number
endpoint: string
token: string
temperature?: number
topP?: number
responseFormat?: {type: 'json_schema'; json_schema: unknown} // Processed response format for the API
}
@@ -45,33 +38,29 @@ export interface InferenceResponse {
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 client = new OpenAI({
apiKey: request.token,
baseURL: request.endpoint,
})
const requestBody: ChatCompletionsRequestBody = {
messages: request.messages,
const chatCompletionRequest: OpenAI.Chat.Completions.ChatCompletionCreateParams = {
messages: request.messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
max_tokens: request.maxTokens,
model: request.modelName,
temperature: request.temperature,
top_p: request.topP,
}
// Add response format if specified
if (request.responseFormat) {
requestBody.response_format = request.responseFormat
// eslint-disable-next-line @typescript-eslint/no-explicit-any
chatCompletionRequest.response_format = request.responseFormat as any
}
const response = await client.path('/chat/completions').post({
body: requestBody,
})
if (isUnexpected(response)) {
handleUnexpectedResponse(response)
}
const modelResponse = response.body.choices[0].message.content
const response = await chatCompletion(client, chatCompletionRequest, 'simpleInference')
const modelResponse = response.choices[0]?.message?.content
core.info(`Model response: ${modelResponse || 'No response content'}`)
return modelResponse
return modelResponse || null
}
/**
@@ -83,8 +72,9 @@ export async function mcpInference(
): Promise<string | null> {
core.info('Running GitHub MCP inference with tools')
const client = ModelClient(request.endpoint, new AzureKeyCredential(request.token), {
userAgentOptions: {userAgentPrefix: 'github-actions-ai-inference'},
const client = new OpenAI({
apiKey: request.token,
baseURL: request.endpoint,
})
// Start with the pre-processed messages
@@ -92,57 +82,69 @@ export async function mcpInference(
let iterationCount = 0
const maxIterations = 5 // Prevent infinite loops
// We want to use response_format (e.g. JSON) on the last iteration only, so the model can output
// the final result in the expected format without interfering with tool calls
let finalMessage = false
while (iterationCount < maxIterations) {
iterationCount++
core.info(`MCP inference iteration ${iterationCount}`)
const requestBody: ChatCompletionsRequestBody = {
messages: messages,
const chatCompletionRequest: OpenAI.Chat.Completions.ChatCompletionCreateParams = {
messages: messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
max_tokens: request.maxTokens,
model: request.modelName,
tools: githubMcpClient.tools,
temperature: request.temperature,
top_p: request.topP,
}
// Add response format if specified (only on first iteration to avoid conflicts)
if (iterationCount === 1 && request.responseFormat) {
requestBody.response_format = request.responseFormat
// Add response format if specified (only on final iteration to avoid conflicts with tool calls)
if (finalMessage && request.responseFormat) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
chatCompletionRequest.response_format = request.responseFormat as any
} else {
chatCompletionRequest.tools = githubMcpClient.tools as OpenAI.Chat.Completions.ChatCompletionTool[]
}
const response = await client.path('/chat/completions').post({
body: requestBody,
})
try {
const response = await chatCompletion(client, chatCompletionRequest, `mcpInference iteration ${iterationCount}`)
if (isUnexpected(response)) {
handleUnexpectedResponse(response)
const assistantMessage = response.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 as ToolCall[]}),
})
if (!toolCalls || toolCalls.length === 0) {
core.info('No tool calls requested, ending GitHub MCP inference loop')
if (request.responseFormat && !finalMessage) {
core.info('Making one more MCP loop with the requested response format...')
messages.push({
role: 'user',
content: `Please provide your response in the exact ${request.responseFormat.type} format specified.`,
})
finalMessage = true
continue
} else {
return modelResponse || null
}
}
core.info(`Model requested ${toolCalls.length} tool calls`)
const toolResults = await executeToolCalls(githubMcpClient.client, toolCalls as ToolCall[])
messages.push(...toolResults)
core.info('Tool results added, continuing conversation...')
} catch (error) {
core.error(`OpenAI API error: ${error}`)
throw error
}
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 GitHub MCP inference loop')
return modelResponse
}
core.info(`Model requested ${toolCalls.length} tool calls`)
// Execute all tool calls via GitHub MCP
const toolResults = await executeToolCalls(githubMcpClient.client, toolCalls)
// Add tool results to the conversation
messages.push(...toolResults)
core.info('Tool results added, continuing conversation...')
}
core.warning(`GitHub MCP inference loop exceeded maximum iterations (${maxIterations})`)
@@ -155,3 +157,43 @@ export async function mcpInference(
return lastAssistantMessage?.content || null
}
/**
* Wrapper around OpenAI chat.completions.create with defensive handling for cases where
* the SDK returns a raw string (e.g., unexpected content-type or streaming body) instead of
* a parsed object. Ensures an object with a 'choices' array is returned or throws a descriptive error.
*/
async function chatCompletion(
client: OpenAI,
params: OpenAI.Chat.Completions.ChatCompletionCreateParams,
context: string,
): Promise<OpenAI.Chat.Completions.ChatCompletion> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any = await client.chat.completions.create(params)
core.debug(`${context}: raw response typeof=${typeof response}`)
if (typeof response === 'string') {
// Attempt to parse if we unexpectedly received a string
try {
response = JSON.parse(response)
} catch (e) {
const preview = response.slice(0, 400)
throw new Error(
`${context}: Chat completion response was a string and not valid JSON (${(e as Error).message}). Preview: ${preview}`,
)
}
}
if (!response || typeof response !== 'object' || !('choices' in response)) {
const preview = JSON.stringify(response)?.slice(0, 800)
throw new Error(`${context}: Unexpected response shape (no choices). Preview: ${preview}`)
}
return response as OpenAI.Chat.Completions.ChatCompletion
} catch (err) {
// Re-throw after logging for upstream handling
core.error(`${context}: chatCompletion failed: ${err}`)
throw err
}
}

View File

@@ -1,13 +1,16 @@
import * as core from '@actions/core'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import * as tmp from 'tmp'
import {connectToGitHubMCP} from './mcp.js'
import {simpleInference, mcpInference} from './inference.js'
import {loadContentFromFileOrInput, buildInferenceRequest} from './helpers.js'
import {loadPromptFile, parseTemplateVariables, isPromptYamlFile, PromptConfig} from './prompt.js'
const RESPONSE_FILE = 'modelResponse.txt'
import {
loadPromptFile,
parseTemplateVariables,
isPromptYamlFile,
PromptConfig,
parseFileTemplateVariables,
} from './prompt.js'
/**
* The main function for the action.
@@ -15,9 +18,15 @@ const RESPONSE_FILE = 'modelResponse.txt'
* @returns Resolves when the action is complete.
*/
export async function run(): Promise<void> {
let responseFile: tmp.FileResult | null = null
// Set up graceful cleanup for temporary files on process exit
tmp.setGracefulCleanup()
try {
const promptFilePath = core.getInput('prompt-file')
const inputVariables = core.getInput('input')
const fileInputVariables = core.getInput('file_input')
let promptConfig: PromptConfig | undefined = undefined
let systemPrompt: string | undefined = undefined
@@ -27,8 +36,10 @@ export async function run(): Promise<void> {
if (promptFilePath && isPromptYamlFile(promptFilePath)) {
core.info('Using prompt YAML file format')
// Parse template variables
const templateVariables = parseTemplateVariables(inputVariables)
// Parse template variables from both string inputs and file-based inputs
const stringVars = parseTemplateVariables(inputVariables)
const fileVars = parseFileTemplateVariables(fileInputVariables)
const templateVariables = {...stringVars, ...fileVars}
// Load and process prompt file
promptConfig = loadPromptFile(promptFilePath, templateVariables)
@@ -42,7 +53,11 @@ export async function run(): Promise<void> {
// Get common parameters
const modelName = promptConfig?.model || core.getInput('model')
const maxTokens = parseInt(core.getInput('max-tokens'), 10)
let maxTokens = promptConfig?.modelParameters?.maxTokens ?? core.getInput('max-tokens')
if (typeof maxTokens === 'string') {
maxTokens = parseInt(maxTokens, 10)
}
const token = process.env['GITHUB_TOKEN'] || core.getInput('token')
if (token === undefined) {
@@ -60,6 +75,8 @@ export async function run(): Promise<void> {
systemPrompt,
prompt,
modelName,
promptConfig?.modelParameters?.temperature,
promptConfig?.modelParameters?.topP,
maxTokens,
endpoint,
token,
@@ -84,22 +101,37 @@ export async function run(): Promise<void> {
core.setOutput('response', modelResponse || '')
const responseFilePath = path.join(tempDir(), RESPONSE_FILE)
core.setOutput('response-file', responseFilePath)
// Create a secure temporary file instead of using the temp directory directly
responseFile = tmp.fileSync({
prefix: 'modelResponse-',
postfix: '.txt',
})
core.setOutput('response-file', responseFile.name)
if (modelResponse && modelResponse !== '') {
fs.writeFileSync(responseFilePath, modelResponse, 'utf-8')
fs.writeFileSync(responseFile.name, modelResponse, 'utf-8')
}
} catch (error) {
if (error instanceof Error) {
core.setFailed(error.message)
} else {
core.setFailed('An unexpected error occurred')
core.setFailed(`An unexpected error occurred: ${JSON.stringify(error, null, 2)}`)
}
// Force exit to prevent hanging on open connections
process.exit(1)
} finally {
// Explicit cleanup of temporary file if it was created
if (responseFile) {
try {
responseFile.removeCallback()
} catch (cleanupError) {
// Log cleanup errors but don't fail the action
core.warning(`Failed to cleanup temporary file: ${cleanupError}`)
}
}
}
}
function tempDir(): string {
const tempDirectory = process.env['RUNNER_TEMP'] || os.tmpdir()
return tempDirectory
// Force exit to prevent hanging on open connections
process.exit(0)
}

View File

@@ -7,9 +7,16 @@ export interface PromptMessage {
content: string
}
export interface ModelParameters {
maxTokens?: number
temperature?: number
topP?: number
}
export interface PromptConfig {
messages: PromptMessage[]
model?: string
modelParameters?: ModelParameters
responseFormat?: 'text' | 'json_schema'
jsonSchema?: string
}
@@ -37,6 +44,47 @@ export function parseTemplateVariables(input: string): TemplateVariables {
}
}
/**
* Parse file-based template variables from YAML input string. The YAML should map
* variable names to file paths. File contents are read and returned as variables.
*/
export function parseFileTemplateVariables(fileInput: string): TemplateVariables {
if (!fileInput.trim()) {
return {}
}
try {
const parsed = yaml.load(fileInput) as Record<string, unknown>
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('File template variables must be a YAML object')
}
const result: TemplateVariables = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== 'string') {
throw new Error(`File template variable '${key}' must be a string file path`)
}
const filePath = value
if (!fs.existsSync(filePath)) {
throw new Error(`File for template variable '${key}' was not found: ${filePath}`)
}
try {
result[key] = fs.readFileSync(filePath, 'utf-8')
} catch (err) {
throw new Error(
`Failed to read file for template variable '${key}' at path '${filePath}': ${err instanceof Error ? err.message : 'Unknown error'}`,
)
}
}
return result
} catch (error) {
throw new Error(
`Failed to parse file template variables: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
}
}
/**
* Replace template variables in text using {{variable}} syntax
*/
@@ -60,11 +108,8 @@ export function loadPromptFile(filePath: string, templateVariables: TemplateVari
const fileContent = fs.readFileSync(filePath, 'utf-8')
// Apply template variable substitution
const processedContent = replaceTemplateVariables(fileContent, templateVariables)
try {
const config = yaml.load(processedContent) as PromptConfig
const config = yaml.load(fileContent) as PromptConfig
if (!config.messages || !Array.isArray(config.messages)) {
throw new Error('Prompt file must contain a "messages" array')
@@ -80,6 +125,14 @@ export function loadPromptFile(filePath: string, templateVariables: TemplateVari
}
}
// Prepare messages by replacing template variables with actual content
config.messages = config.messages.map(msg => {
return {
...msg,
content: replaceTemplateVariables(msg.content, templateVariables),
}
})
return config
} catch (error) {
throw new Error(`Failed to parse prompt file: ${error instanceof Error ? error.message : 'Unknown error'}`)