diff --git a/.github/workflows/dispatch.yaml b/.github/workflows/dispatch.yaml new file mode 100644 index 0000000..7d95127 --- /dev/null +++ b/.github/workflows/dispatch.yaml @@ -0,0 +1,35 @@ +name: Update GPTScript Version +on: + repository_dispatch: + types: release + +jobs: + update-gptscript-dep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.BOT_GH_TOKEN }} + - name: Install jq + uses: dcarbone/install-jq-action@v2.1.0 + - name: Update GPTScript Version + run: | + jq '.version = "${{ github.event.client_payload.tag }}"' package.json > temp.json && mv temp.json package.json + sed -i 's/version: "v.*"/version: "${{ github.event.client_payload.tag }}"/' scripts/install-binary.js + - uses: actions/setup-node@v4 + with: + node-version: 21 + - name: Install + run: npm i + - uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: Automated GPTScript Version Update + file_pattern: 'package*.json scripts/install-binary.js' + tagging_message: ${{ github.event.client_payload.tag }} + - name: Create a GitHub release + uses: ncipollo/release-action@v1 + with: + tag: ${{ github.event.client_payload.tag }} + name: Release ${{ github.event.client_payload.tag }} + generateReleaseNotes: true + prerelease: ${{ contains(github.event.client_payload.tag, '-rc') }} diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index e4b61eb..6ec42cc 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -38,3 +38,4 @@ jobs: git_ref: ${{ github.event.pull_request.head.sha }} secrets: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/push_main.yaml b/.github/workflows/push_main.yaml index c7db5c9..8e7b2cb 100644 --- a/.github/workflows/push_main.yaml +++ b/.github/workflows/push_main.yaml @@ -13,3 +13,4 @@ jobs: git_ref: '' secrets: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml index fd71199..1d959aa 100644 --- a/.github/workflows/run_tests.yaml +++ b/.github/workflows/run_tests.yaml @@ -9,6 +9,8 @@ on: secrets: OPENAI_API_KEY: required: true + ANTHROPIC_API_KEY: + required: true jobs: test-linux: @@ -31,27 +33,7 @@ jobs: env: GPTSCRIPT_BIN: ./gptscriptexe OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} NODE_GPTSCRIPT_SKIP_INSTALL_BINARY: true run: npm test - test-windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 1 - ref: ${{ github.event.pull_request.head.sha }} - - uses: actions/setup-node@v4 - with: - node-version: 21 - - name: Install gptscript - run: | - curl https://get.gptscript.ai/releases/default_windows_amd64_v1/gptscript.exe -o gptscript.exe - - name: Install dependencies - run: npm install - - name: Run Tests - env: - GPTSCRIPT_BIN: .\gptscript.exe - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - NODE_GPTSCRIPT_SKIP_INSTALL_BINARY: true - run: npm test diff --git a/README.md b/README.md index 9b3508c..c17a4c4 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,27 @@ You will see "Hello, World!" in the output of the command. ## GPTScript -The GPTScript instance allows the caller to run gptscript files, tools, and other operations (see below). There are -currently no options for this class, so `new gptscript.GPTScript()` is all you need. Although, the intention is that a -single instance is all you need for the life of your application, you should call `close()` on the instance when you -are done. +The GPTScript instance allows the caller to run gptscript files, tools, and other operations (see below). Note that the +intention is that a single instance is all you need for the life of your application, you should call `close()` on the +instance when you are done. -## Options +## Global Options + +When creating a `GTPScript` instance, you can pass the following global options. These options are also available as +run `Options`. Except `Env`, anything specified as a run option will take precedence over the global +option. Any `env` provided in the run options are appended. + +- `APIKey`: Specify an OpenAI API key for authenticating requests +- `BaseURL`: A base URL for an OpenAI compatible API (the default is `https://api.openai.com/v1`) +- `DefaultModel`: The default model to use for chat completion requests +- `DefaultModelProvider`: The default model provider to use for chat completion requests +- `Env`: Replace the system's environment variables with these in the for `KEY=VAL` + +## Run Options These are optional options that can be passed to the various `exec` functions. None of the options is required, and the defaults will reduce the number of calls made to the Model API. +As noted above, the Global Options are also available to specify here. These options would take precedence. - `cache`: Enable or disable caching. Default (true). - `cacheDir`: Specify the cache directory. @@ -54,23 +66,6 @@ None of the options is required, and the defaults will reduce the number of call ## Functions -### listTools - -Lists all the available built-in tools. - -**Usage:** - -```javascript -const gptscript = require('@gptscript-ai/gptscript'); - -async function listTools() { - const g = new gptscript.GPTScript(); - const tools = await g.listTools(); - console.log(tools); - g.close(); -} -``` - ### listModels Lists all the available models, returns a list. diff --git a/package-lock.json b/package-lock.json index 89edd3b..74a8c5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gptscript-ai/gptscript", - "version": "v0.7.3", + "version": "v0.9.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gptscript-ai/gptscript", - "version": "v0.7.3", + "version": "v0.9.5", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -2938,33 +2938,12 @@ "@types/responselike": "^1.0.0" } }, - "node_modules/@types/eslint": { - "version": "8.56.10", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", - "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==", - "dev": true, - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/@types/graceful-fs": { @@ -3274,11 +3253,12 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, + "license": "MIT", "peer": true, "peerDependencies": { "acorn": "^8" @@ -3911,12 +3891,13 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -4645,10 +4626,11 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.1.tgz", - "integrity": "sha512-4U5pNsuDl0EhuZpq46M5xPslstkviJuhrdobaRDBk2Jy2KO37FDAJl4lb2KlNabxT0m4MTK2UHNrsAcphE8nyw==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -5067,10 +5049,11 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -5799,6 +5782,7 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -8015,12 +7999,13 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -10014,6 +9999,7 @@ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -10510,22 +10496,22 @@ } }, "node_modules/webpack": { - "version": "5.91.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.91.0.tgz", - "integrity": "sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", + "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.16.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", diff --git a/package.json b/package.json index 3b64747..ccc8437 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gptscript-ai/gptscript", - "version": "v0.8.0-rc3", + "version": "v0.9.5", "description": "Run gptscript in node.js", "source": "src/gptscript.ts", "main": "dist/gptscript.js", @@ -19,6 +19,7 @@ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "postinstall": "node scripts/install-binary.js", "clean": "rm -rf dist", + "prepare": "npm run build", "build": "tsc" }, "keywords": [ diff --git a/scripts/install-binary.js b/scripts/install-binary.js index b385eb2..9ed7bf1 100644 --- a/scripts/install-binary.js +++ b/scripts/install-binary.js @@ -17,14 +17,18 @@ async function downloadAndExtract(url, saveDirectory) { return new Promise((resolve, reject) => { dlh.on('end', () => { + const downloadedFilePath = path.join(dlh.getDownloadPath()); if (url.endsWith('.zip')) { - const zip = new AdmZip(path.join(dlh.getDownloadPath())); + const zip = new AdmZip(downloadedFilePath); zip.extractAllTo(saveDirectory, true); + fs.unlinkSync(downloadedFilePath); } else if (url.endsWith('.tar.gz')) { tar.x({ - file: path.join(dlh.getDownloadPath()), + file: downloadedFilePath, cwd: saveDirectory, - }); + }).then(() => { + fs.unlinkSync(downloadedFilePath); // Delete the tar.gz file after extraction + }).catch((error) => reject(error)); } resolve(); }); @@ -68,7 +72,7 @@ if (process.platform === 'win32') { const gptscript_info = { name: "gptscript", url: "https://github.com/gptscript-ai/gptscript/releases/download/", - version: "v0.8.0-rc3" + version: "v0.9.5" } const pltfm = { @@ -121,10 +125,8 @@ async function needToInstall() { console.log(`Downloading and extracting gptscript binary from ${url}...`); try { - downloadAndExtract(url, outputDir) + await downloadAndExtract(url, outputDir); } catch (error) { - console.error('Error downloading and extracting:', error) + console.error('Error downloading and extracting:', error); } })(); - - diff --git a/src/gptscript.ts b/src/gptscript.ts index 4a146f6..f139d17 100644 --- a/src/gptscript.ts +++ b/src/gptscript.ts @@ -2,773 +2,1373 @@ import http from "http" import path from "path" import child_process from "child_process" import {fileURLToPath} from "url" -import net from "net" +import {gunzipSync} from "zlib" +import https from "https" + +export interface GlobalOpts { + URL?: string + Token?: string + CacheDir?: string + APIKey?: string + BaseURL?: string + DefaultModel?: string + DefaultModelProvider?: string + DatasetTool?: string + WorkspaceTool?: string + Env?: string[] +} + +function globalOptsToEnv(env: NodeJS.ProcessEnv, opts?: GlobalOpts) { + if (!opts) { + return + } + + if (opts.APIKey) { + env["OPENAI_API_KEY"] = opts.APIKey + } + if (opts.BaseURL) { + env["OPENAI_BASE_URL"] = opts.BaseURL + } + if (opts.DefaultModel) { + env["GPTSCRIPT_SDKSERVER_DEFAULT_MODEL"] = opts.DefaultModel + } + if (opts.DefaultModelProvider) { + env["GPTSCRIPT_SDKSERVER_DEFAULT_MODEL_PROVIDER"] = opts.DefaultModelProvider + } +} export interface RunOpts { - input?: string - disableCache?: boolean - quiet?: boolean - chdir?: string - subTool?: string - workspace?: string - chatState?: string - confirm?: boolean - prompt?: boolean - env?: string[] + input?: string + disableCache?: boolean + quiet?: boolean + chdir?: string + subTool?: string + workspace?: string + chatState?: string + confirm?: boolean + prompt?: boolean + credentialOverrides?: string[] + credentialContexts?: string[] + location?: string + env?: string[] + forceSequential?: boolean + + URL?: string + Token?: string + CacheDir?: string + APIKey?: string + BaseURL?: string + DefaultModel?: string } export enum RunEventType { - Event = "event", - RunStart = "runStart", - RunFinish = "runFinish", - CallStart = "callStart", - CallChat = "callChat", - CallSubCalls = "callSubCalls", - CallProgress = "callProgress", - CallConfirm = "callConfirm", - CallContinue = "callContinue", - CallFinish = "callFinish", - - Prompt = "prompt" + Event = "event", + RunStart = "runStart", + RunFinish = "runFinish", + CallStart = "callStart", + CallChat = "callChat", + CallSubCalls = "callSubCalls", + CallProgress = "callProgress", + CallConfirm = "callConfirm", + CallContinue = "callContinue", + CallFinish = "callFinish", + + Prompt = "prompt" } export class GPTScript { - private static serverURL: string = "" - private static serverProcess: child_process.ChildProcess - private static instanceCount: number = 0 - - - private ready: boolean - - constructor() { - this.ready = false - GPTScript.instanceCount++ - if (!GPTScript.serverURL) { - GPTScript.serverURL = "http://" + (process.env.GPTSCRIPT_URL || "127.0.0.1:0") - } - if (GPTScript.instanceCount === 1 && process.env.GPTSCRIPT_DISABLE_SERVER !== "true") { - const u = new URL(GPTScript.serverURL) - if (u.port === "0") { - const srv = net.createServer() - const s = srv.listen(0, () => { - GPTScript.serverURL = "http://" + u.hostname + ":" + String((s.address() as net.AddressInfo).port) - srv.close() - - GPTScript.serverProcess = child_process.spawn(getCmdPath(), ["--listen-address", GPTScript.serverURL.replace("http://", ""), "sdkserver"], { - env: process.env, - stdio: ["pipe"] - }) - - process.on("exit", (code) => { - GPTScript.serverProcess.stdin?.end() - GPTScript.serverProcess.kill(code) - }) - }) - } - } - } - - close(): void { - GPTScript.instanceCount-- - if (GPTScript.instanceCount === 0 && GPTScript.serverProcess) { - GPTScript.serverProcess.kill("SIGTERM") - GPTScript.serverProcess.stdin?.end() - } - } - - listTools(): Promise { - return this.runBasicCommand("list-tools") - } - - listModels(): Promise { - return this.runBasicCommand("list-models") - } - - version(): Promise { - return this.runBasicCommand("version") - } - - async runBasicCommand(cmd: string): Promise { - if (!this.ready) { - this.ready = await this.testGPTScriptURL(20) - } - const r = new RunSubcommand(cmd, "", {}, GPTScript.serverURL) - r.requestNoStream(null) - return r.text() - } - - /** - * Runs a tool with the specified name and options. - * - * @param {string} toolName - The name of the tool to run. Can be a file path, URL, or GitHub URL. - * @param {RunOpts} [opts={}] - The options for running the tool. - * @return {Run} The Run object representing the running tool. - */ - async run(toolName: string, opts: RunOpts = {}): Promise { - if (!this.ready) { - this.ready = await this.testGPTScriptURL(20) - } - return (new Run("run", toolName, opts, GPTScript.serverURL)).nextChat(opts.input) - } - - /** - * Evaluates the given tool and returns a Run object. - * - * @param {ToolDef | ToolDef[]} tool - The tool to be evaluated. Can be a single ToolDef object or an array of ToolDef objects. - * @param {RunOpts} [opts={}] - Optional options for the evaluation. - * @return {Run} The Run object representing the evaluation. - */ - async evaluate(tool: ToolDef | ToolDef[], opts: RunOpts = {}): Promise { - if (!this.ready) { - this.ready = await this.testGPTScriptURL(20) - } - - return (new Run("evaluate", tool, opts, GPTScript.serverURL)).nextChat(opts.input) - } - - async parse(fileName: string): Promise { - if (!this.ready) { - this.ready = await this.testGPTScriptURL(20) - } - const r: Run = new RunSubcommand("parse", fileName, {}, GPTScript.serverURL) - r.request({file: fileName}) - return parseBlocksFromNodes((await r.json()).nodes) - } - - async parseTool(toolContent: string): Promise { - if (!this.ready) { - this.ready = await this.testGPTScriptURL(20) - } - const r: Run = new RunSubcommand("parse", "", {}, GPTScript.serverURL) - r.request({content: toolContent}) - return parseBlocksFromNodes((await r.json()).nodes) - } - - async stringify(blocks: Block[]): Promise { - if (!this.ready) { - this.ready = await this.testGPTScriptURL(20) - } - const nodes: any[] = [] - - for (const block of blocks) { - if (block.type === "tool") { - nodes.push({ - toolNode: { - tool: block - } - }) - } else if (block.type === "text") { - nodes.push({ - textNode: { - text: "!" + (block.format || "text") + "\n" + block.content - } - }) - } - } - - const r: Run = new RunSubcommand("fmt", "", {}, GPTScript.serverURL) - r.request({nodes: nodes}) - return r.text() - } - - async confirm(response: AuthResponse): Promise { - if (!this.ready) { - this.ready = await this.testGPTScriptURL(20) - } - const resp = await fetch(`${GPTScript.serverURL}/confirm/${response.id}`, { - method: "POST", - body: JSON.stringify(response) - }) - - if (resp.status < 200 || resp.status >= 400) { - throw new Error(`Failed to confirm ${response.id}: ${await resp.text()}`) - } - } - - async promptResponse(response: PromptResponse): Promise { - if (!this.ready) { - this.ready = await this.testGPTScriptURL(20) - } - const resp = await fetch(`${GPTScript.serverURL}/prompt-response/${response.id}`, { - method: "POST", - body: JSON.stringify(response.responses) - }) - - if (resp.status < 200 || resp.status >= 400) { - throw new Error(`Failed to respond to prompt ${response.id}: ${await resp.text()}`) - } - } - - private async testGPTScriptURL(count: number): Promise { - try { - await fetch(`${GPTScript.serverURL}/healthz`) - return true - } catch { - if (count === 0) { - throw new Error("Failed to wait for gptscript to be ready") - } - await new Promise(r => setTimeout(r, 500)) - return this.testGPTScriptURL(count - 1) - } - } + private static serverURL: string = "" + private static serverProcess: child_process.ChildProcess + private static instanceCount: number = 0 + + + private readonly opts: GlobalOpts + + constructor(opts?: GlobalOpts) { + this.opts = opts || {} + GPTScript.instanceCount++ + + let startSDK = !GPTScript.serverProcess && !GPTScript.serverURL && !this.opts.URL + + if (!GPTScript.serverURL) { + GPTScript.serverURL = process.env.GPTSCRIPT_URL ?? "" + startSDK = startSDK && !GPTScript.serverURL + } + + if (!this.opts.Token) { + this.opts.Token = process.env.GPTSCRIPT_TOKEN + } + + if (startSDK) { + let env = process.env + if (this.opts.Env) { + env = { + "NODE_ENV": process.env.NODE_ENV + } + for (const v of this.opts.Env) { + const equalIndex = v.indexOf("=") + if (equalIndex === -1) { + env[v] = "" + } else { + env[v.substring(0, equalIndex)] = v.substring(equalIndex + 1) + } + } + } + + globalOptsToEnv(env, this.opts) + process.on("exit", (code) => { + if (GPTScript.serverProcess) { + GPTScript.serverProcess.stdin?.end() + GPTScript.serverProcess.kill(code) + } + }) + + GPTScript.serverProcess = child_process.spawn(getCmdPath(), ["sys.sdkserver", "--listen-address", "127.0.0.1:0"], { + env: env, + stdio: ["pipe", "ignore", "pipe"] + }) + + GPTScript.serverProcess.stderr?.on("data", (data) => { + let url = data.toString().trim() + if (url.includes("=")) { + url = url.substring(url.indexOf("=") + 1) + } + + GPTScript.serverURL = `http://${url}` + + GPTScript.serverProcess.stderr?.removeAllListeners() + }) + } else { + if (!this.opts.URL) { + this.opts.URL = GPTScript.serverURL + } + if (this.opts.URL !== "" && !this.opts.URL.startsWith("http://") && !this.opts.URL.startsWith("https://")) { + this.opts.URL = "http://" + this.opts.URL + } + + if (!this.opts.Env) { + this.opts.Env = Object.entries(process.env).map(([k, v]) => `${k}=${v}`) + } + if (this.opts.URL) { + this.opts.Env.push(`GPTSCRIPT_URL=${this.opts.URL}`) + } + + if (this.opts.Token) { + this.opts.Env.push(`GPTSCRIPT_TOKEN=${this.opts.Token}`) + } + } + } + + close(): void { + GPTScript.instanceCount-- + if (GPTScript.instanceCount === 0 && GPTScript.serverProcess) { + GPTScript.serverURL = process.env.GPTSCRIPT_URL ?? "" + GPTScript.serverProcess.kill("SIGTERM") + GPTScript.serverProcess.stdin?.end() + } + } + + async listModels(providers?: string[], credentialOverrides?: string[]): Promise> { + if (this.opts.DefaultModelProvider) { + if (!providers) { + providers = [] + } + providers.push(this.opts.DefaultModelProvider) + } + const result = await this.runBasicCommand("list-models", { + "providers": providers, + "env": this.opts.Env, + "credentialOverrides": credentialOverrides + }) + return await JSON.parse(result) as Array + } + + version(): Promise { + return this.runBasicCommand("version") + } + + async runBasicCommand(cmd: string, body?: any): Promise { + if (!this.opts.URL) { + await this.testGPTScriptURL(20) + } + const r = new RunSubcommand(cmd, "", {URL: this.opts.URL, Token: this.opts.Token}) + r.requestNoStream(body) + return r.text() + } + + /** + * Runs a tool with the specified name and options. + * + * @param {string} toolName - The name of the tool to run. Can be a file path, URL, or GitHub URL. + * @param {RunOpts} [opts={}] - The options for running the tool. + * @return {Run} The Run object representing the running tool. + */ + async run(toolName: string, opts: RunOpts = {}): Promise { + if (!this.opts.URL) { + await this.testGPTScriptURL(20) + } + if (this.opts.Env) { + opts.env = this.opts.Env.concat(opts.env || []) + } + + return (new Run("run", toolName, {...this.opts, ...opts})).nextChat(opts.input) + } + + /** + * Evaluates the given tool and returns a Run object. + * + * @param {ToolDef | ToolDef[]} tool - The tool to be evaluated. Can be a single ToolDef object or an array of ToolDef objects. + * @param {RunOpts} [opts={}] - Optional options for the evaluation. + * @return {Run} The Run object representing the evaluation. + */ + async evaluate(tool: Tool | ToolDef | ToolDef[], opts: RunOpts = {}): Promise { + if (!this.opts.URL) { + await this.testGPTScriptURL(20) + } + if (this.opts.Env) { + opts.env = this.opts.Env.concat(opts.env || []) + } + return (new Run("evaluate", tool, {...this.opts, ...opts})).nextChat(opts.input) + } + + async parse(fileName: string, disableCache?: boolean): Promise { + if (!this.opts.URL) { + await this.testGPTScriptURL(20) + } + const r: Run = new RunSubcommand("parse", fileName, { + disableCache: disableCache, + URL: this.opts.URL, + Token: this.opts.Token + }) + r.request({file: fileName}) + return parseBlocksFromNodes((await r.json()).nodes) + } + + async parseContent(toolContent: string): Promise { + if (!this.opts.URL) { + await this.testGPTScriptURL(20) + } + const r: Run = new RunSubcommand("parse", "", {URL: this.opts.URL, Token: this.opts.Token}) + r.request({content: toolContent}) + return parseBlocksFromNodes((await r.json()).nodes) + } + + async stringify(blocks: Block[]): Promise { + if (!this.opts.URL) { + await this.testGPTScriptURL(20) + } + const nodes: any[] = [] + + for (const block of blocks) { + if (block.type === "text") { + nodes.push({ + textNode: { + text: "!" + (block.format || "text") + "\n" + block.content + } + }) + } else { + nodes.push({ + toolNode: { + tool: block + } + }) + } + } + + const r: Run = new RunSubcommand("fmt", "", {URL: this.opts.URL, Token: this.opts.Token}) + r.request({nodes: nodes}) + return r.text() + } + + async confirm(response: AuthResponse): Promise { + if (!this.opts.URL) { + await this.testGPTScriptURL(20) + } + const resp = await fetch(`${this.opts.URL}/confirm/${response.id}`, { + method: "POST", + body: JSON.stringify(response) + }) + + if (resp.status < 200 || resp.status >= 400) { + throw new Error(`Failed to confirm ${response.id}: ${await resp.text()}`) + } + } + + async promptResponse(response: PromptResponse): Promise { + if (!this.opts.URL) { + await this.testGPTScriptURL(20) + } + const resp = await fetch(`${this.opts.URL}/prompt-response/${response.id}`, { + method: "POST", + body: JSON.stringify(response.responses) + }) + + if (resp.status < 200 || resp.status >= 400) { + throw new Error(`Failed to respond to prompt ${response.id}: ${await resp.text()}`) + } + } + + /** + * Loads a file into a Program. + * + * @param {string} fileName - The name of the file to load. + * @param {boolean} [disableCache] - Whether to disable the cache. + * @param {string} [subTool] - The sub-tool to use. + * @return {Promise} The loaded program. + */ + async load( + fileName: string, + disableCache?: boolean, + subTool?: string + ): Promise { + return this._load({file: fileName, disableCache, subTool}) + } + + /** + * Loads content into a Program. + * + * @param {string} content - The content to load. + * @param {boolean} [disableCache] - Whether to disable the cache. + * @param {string} [subTool] - The sub-tool to use. + * @return {Promise} The loaded program. + */ + async loadContent( + content: string, + disableCache?: boolean, + subTool?: string + ): Promise { + return this._load({content, disableCache, subTool}) + } + + /** + * Loads tools into a Program. + * + * @param {ToolDef[]} toolDefs - The tools to load. + * @param {boolean} [disableCache] - Whether to disable the cache. + * @param {string} [subTool] - The sub-tool to use. + * @return {Promise} The loaded program. + */ + async loadTools( + toolDefs: ToolDef[], + disableCache?: boolean, + subTool?: string + ): Promise { + return this._load({toolDefs, disableCache, subTool}) + } + + async listCredentials(context: Array, allContexts: boolean): Promise> { + if (!this.opts.URL) { + await this.testGPTScriptURL(20) + } + + const r: Run = new RunSubcommand("credentials", "", {URL: this.opts.URL, Token: this.opts.Token}) + r.request({context, allContexts}) + const out = await r.json() + return out.map((c: any) => jsonToCredential(JSON.stringify(c))) + } + + async createCredential(credential: Credential): Promise { + await this.runBasicCommand("credentials/create", { + content: credentialToJSON(credential) + }) + } + + async revealCredential(context: Array, name: string): Promise { + const resp = await this.runBasicCommand("credentials/reveal", { + context, + name + }) + return jsonToCredential(resp) + } + + async deleteCredential(context: string, name: string): Promise { + await this.runBasicCommand("credentials/delete", { + context: [context], + name + }) + } + + // returns an array of dataset IDs + async listDatasets(): Promise> { + const result = await this.runBasicCommand("datasets", { + input: "{}", + datasetTool: this.opts.DatasetTool ?? "", + env: this.opts.Env + }) + return JSON.parse(result) as Array + } + + async addDatasetElements(elements: Array, opts: { + name?: string, + description?: string, + datasetID?: string + }): Promise { + const serializableElements = elements.map(e => { + return { + name: e.name, + description: e.description, + contents: e.contents, + binaryContents: Buffer.from(e.binaryContents ?? Buffer.from("")).toString("base64") + } + }) + + return await this.runBasicCommand("datasets/add-elements", { + input: JSON.stringify({ + name: opts.name ?? "", + description: opts.description ?? "", + datasetID: opts.datasetID ?? "", + elements: serializableElements + }), + datasetTool: this.opts.DatasetTool ?? "", + env: this.opts.Env + }) + } + + async listDatasetElements(datasetID: string): Promise> { + const result = await this.runBasicCommand("datasets/list-elements", { + input: JSON.stringify({datasetID}), + datasetTool: this.opts.DatasetTool ?? "", + env: this.opts.Env + }) + return JSON.parse(result) as Array + } + + async getDatasetElement(datasetID: string, elementName: string): Promise { + const result = await this.runBasicCommand("datasets/get-element", { + input: JSON.stringify({datasetID, name: elementName}), + datasetTool: this.opts.DatasetTool ?? "", + env: this.opts.Env + }) + + const element = JSON.parse(result) + return { + name: element.name, + description: element.description, + contents: element.contents, + binaryContents: Buffer.from(element.binaryContents ?? "", "base64") + } + } + + async createWorkspace(providerType: string, ...fromWorkspaces: string[]): Promise { + const out = await this.runBasicCommand("workspaces/create", { + providerType: providerType, + fromWorkspaceIDs: fromWorkspaces, + workspaceTool: this.opts.WorkspaceTool, + env: this.opts.Env, + }) + return out.trim() + } + + async deleteWorkspace(workspaceID: string): Promise { + if (!workspaceID) { + return Promise.reject("workspace ID cannot be empty") + } + + await this.runBasicCommand("workspaces/delete", { + id: workspaceID, + workspaceTool: this.opts.WorkspaceTool, + env: this.opts.Env, + }) + } + + async listFilesInWorkspace(prefix?: string, workspaceID?: string): Promise> { + if (!workspaceID) { + workspaceID = process.env.GPTSCRIPT_WORKSPACE_ID ?? "" + } + const out = await this.runBasicCommand("workspaces/list", { + id: workspaceID, + prefix: prefix, + workspaceTool: this.opts.WorkspaceTool, + env: this.opts.Env, + }) + return JSON.parse(out) + } + + async removeAll(withPrefix?: string, workspaceID?: string): Promise { + if (!workspaceID) { + workspaceID = process.env.GPTSCRIPT_WORKSPACE_ID ?? "" + } + await this.runBasicCommand("workspaces/remove-all-with-prefix", { + id: workspaceID, + prefix: withPrefix, + workspaceTool: this.opts.WorkspaceTool, + env: this.opts.Env, + }) + } + + async writeFileInWorkspace(filePath: string, content: ArrayBuffer, workspaceID?: string): Promise { + if (!workspaceID) { + workspaceID = process.env.GPTSCRIPT_WORKSPACE_ID ?? "" + } + await this.runBasicCommand("workspaces/write-file", { + id: workspaceID, + filePath: filePath, + contents: Buffer.from(content).toString("base64"), + workspaceTool: this.opts.WorkspaceTool, + env: this.opts.Env, + }) + } + + async deleteFileInWorkspace(filePath: string, workspaceID?: string): Promise { + if (!workspaceID) { + workspaceID = process.env.GPTSCRIPT_WORKSPACE_ID ?? "" + } + await this.runBasicCommand("workspaces/delete-file", { + id: workspaceID, + filePath: filePath, + workspaceTool: this.opts.WorkspaceTool, + env: this.opts.Env, + }) + } + + async readFileInWorkspace(filePath: string, workspaceID?: string): Promise { + if (!workspaceID) { + workspaceID = process.env.GPTSCRIPT_WORKSPACE_ID ?? "" + } + const out = await this.runBasicCommand("workspaces/read-file", { + id: workspaceID, + filePath: filePath, + workspaceTool: this.opts.WorkspaceTool, + env: this.opts.Env, + }) + return Buffer.from(out.trim(), "base64") + } + + async statFileInWorkspace(filePath: string, workspaceID?: string): Promise { + if (!workspaceID) { + workspaceID = process.env.GPTSCRIPT_WORKSPACE_ID ?? "" + } + const out = await this.runBasicCommand("workspaces/stat-file", { + id: workspaceID, + filePath: filePath, + workspaceTool: this.opts.WorkspaceTool, + env: this.opts.Env, + }) + + return JSON.parse(out) + } + + /** + * Helper method to handle the common logic for loading. + * + * @param {any} payload - The payload to send in the request. + * @return {Promise} The loaded program. + */ + private async _load(payload: any): Promise { + if (!this.opts.URL) { + await this.testGPTScriptURL(20) + } + const r: Run = new RunSubcommand("load", payload.toolDefs || [], {URL: this.opts.URL, Token: this.opts.Token}) + + r.request(payload) + return (await r.json()) as LoadResponse + } + + private async testGPTScriptURL(count: number): Promise { + while (count > 0) { + try { + await fetch(`${GPTScript.serverURL}/healthz`) + this.opts.URL = GPTScript.serverURL + if (!this.opts.Env) { + this.opts.Env = [] + } + this.opts.Env.push(`GPTSCRIPT_URL=${this.opts.URL}`) + if (this.opts.Token) { + this.opts.Env.push(`GPTSCRIPT_TOKEN=${this.opts.Token}`) + } + + return + } catch { + if (count === 0) { + } + await new Promise(r => setTimeout(r, 500)) + count-- + } + } + + throw new Error("Failed to wait for gptscript to be ready") + } +} + +export interface FileInfo { + workspaceID: string + name: string + size: number + modTime: string } export class Run { - public readonly id: string - public readonly opts: RunOpts - public readonly tools?: ToolDef | ToolDef[] | string - public state: RunState = RunState.Creating - public calls: Record = {} - public err: string = "" - - protected stdout?: string - - private readonly gptscriptURL?: string - private readonly requestPath: string = "" - private promise?: Promise - private req?: http.ClientRequest - private stderr?: string - private callbacks: Record void)[]> = {} - private chatState?: string - private parentCallId: string = "" - private prg?: Program - private respondingToolId?: string - - constructor(subCommand: string, tools: ToolDef | ToolDef[] | string, opts: RunOpts, gptscriptURL?: string) { - this.id = randomId("run-") - this.requestPath = subCommand - this.opts = opts - this.tools = tools - - this.gptscriptURL = gptscriptURL - } - - nextChat(input: string = ""): Run { - if (this.state !== RunState.Continue && this.state !== RunState.Creating && this.state !== RunState.Error) { - throw (new Error(`Run must in creating, continue or error state, not ${this.state}`)) - } - - let run = this - if (run.state !== RunState.Creating) { - run = new (this.constructor as any)(this.requestPath, this.tools, this.opts, this.gptscriptURL) - } - - if (this.chatState && this.state === RunState.Continue) { - // Only update the chat state if the previous run didn't error. - // The chat state on opts will be the chat state for the last successful run. - this.opts.chatState = this.chatState - } - run.opts.input = input - if (Array.isArray(this.tools)) { - run.request({toolDefs: this.tools, ...this.opts}) - } else if (typeof this.tools === "string") { - run.request({file: this.tools, ...this.opts}) - } else { - // In this last case, this.tools is a single ToolDef. - run.request({toolDefs: [this.tools], ...this.opts}) - } - - return run - } - - processStdout(data: string | object): string { - if (typeof data === "string") { - if (data.trim() === "") { - return "" - } - - try { - data = JSON.parse(data) - } catch (e) { - return data as string - } - } - - const out = data as ChatState - if (out.done === undefined || !out.done) { - this.chatState = JSON.stringify(out.state) - this.state = RunState.Continue - this.respondingToolId = out.toolId - } else { - this.state = RunState.Finished - this.chatState = undefined - } - - return "" - } - - request(tool: any) { - if (!this.gptscriptURL) { - throw new Error("request() requires gptscriptURL to be set") - } - const options = this.requestOptions(this.gptscriptURL, this.requestPath, tool) - options.headers = {"Transfer-Encoding": "chunked", ...options.headers} as any - - this.promise = new Promise(async (resolve, reject) => { - let frag = "" - this.req = http.request(options, (res: http.IncomingMessage) => { - this.state = RunState.Running - res.on("data", (chunk: any) => { - for (let line of (frag + chunk.toString()).split("\n")) { - const c = line.replace(/^(data: )/, "").trim() - if (!c) { - continue - } - - if (c === "[DONE]") { - return - } - - let e: any - try { - e = JSON.parse(c) - } catch { - frag = c - return - } - - if (e.stderr) { - this.stderr = (this.stderr || "") + (typeof e.stderr === "string" ? e.stderr : JSON.stringify(e.stderr)) - frag = "" - } else if (e.stdout) { - frag = this.processStdout(e.stdout) - } else { - frag = this.emitEvent(c) - } - } - }) - - res.on("end", () => { - if (this.state === RunState.Running || this.state === RunState.Finished || this.state === RunState.Continue) { - if (this.stdout) { - if (this.state !== RunState.Continue) { - this.state = RunState.Finished - } - resolve(this.stdout) - } else { - this.state = RunState.Error - reject(this.stderr) - } - } else if (this.state === RunState.Error) { - reject(this.err) - } - }) - - res.on("aborted", () => { - if (this.state !== RunState.Finished && this.state !== RunState.Error) { - this.state = RunState.Error - this.err = "Run has been aborted" - reject(this.err) - } - }) - - res.on("error", (error: Error) => { - if (this.state !== RunState.Error) { - this.state = RunState.Error - this.err = error.message || "" - } - reject(this.err) - }) - }) - - this.req.on("error", (error: Error) => { - if (this.state !== RunState.Error) { - this.state = RunState.Error - this.err = error.message || "" - } - reject(this.err) - }) - - this.req.write(JSON.stringify({...tool, ...this.opts})) - this.req.end() - }) - } - - requestNoStream(tool: any) { - if (!this.gptscriptURL) { - throw new Error("request() requires gptscriptURL to be set") - } - - const options = this.requestOptions(this.gptscriptURL, this.requestPath, tool) as any - if (tool) { - options.body = {...tool, ...this.opts} - } - const req = new Request(this.gptscriptURL + "/" + this.requestPath, options) - - this.promise = new Promise(async (resolve, reject) => { - fetch(req).then(resp => resp.json()).then(res => resolve(res.stdout)).catch(e => { - reject(e) - }) - }) - } - - requestOptions(gptscriptURL: string, path: string, tool: any) { - let method = "GET" - if (tool) { - method = "POST" - } - - const url = new URL(gptscriptURL) - - return { - hostname: url.hostname, - port: url.port || 80, - protocol: url.protocol || "http:", - path: "/" + path, - method: method, - headers: { - "Content-Type": "application/json" - }, - } - } - - public on(event: RunEventType.RunStart | RunEventType.RunFinish, listener: (data: RunFrame) => void): this; - public on(event: RunEventType.CallStart | RunEventType.CallProgress | RunEventType.CallContinue | RunEventType.CallChat | RunEventType.CallConfirm | RunEventType.CallFinish, listener: (data: CallFrame) => void): this; - public on(event: RunEventType.Prompt, listener: (data: PromptFrame) => void): this; - public on(event: RunEventType.Event, listener: (data: Frame) => void): this; - public on(event: RunEventType, listener: (data: any) => void): this { - if (!this.callbacks[event]) { - this.callbacks[event] = [] - } - - this.callbacks[event].push(listener) - - return this - } - - public text(): Promise { - if (this.err) { - throw new Error(this.err) - } - - if (!this.promise) { - throw new Error("Run not started") - } - - return this.promise - } - - public async json(): Promise { - return JSON.parse(await this.text()) - } - - public currentChatState(): string | undefined { - return this.chatState - } - - public parentCallFrame(): CallFrame | undefined { - if (this.parentCallId) { - return this.calls[this.parentCallId] - } - - return undefined - } - - public program(): Program | undefined { - return this.prg - } - - public respondingTool(): Tool | undefined { - return this.respondingToolId ? this.prg?.toolSet[this.respondingToolId] : undefined - } - - public close(): void { - if (this.req) { - this.req.destroy() - return - } - throw new Error("Run not started") - } - - private emitEvent(data: string): string { - for (let event of data.split("\n")) { - event = event.trim() - - if (!event) { - continue - } - let f: Frame - try { - const obj = JSON.parse(event) - if (obj.run) { - f = obj.run as Frame - } else if (obj.call) { - f = obj.call as Frame - } else if (obj.prompt) { - f = obj.prompt as Frame - } else { - return event - } - } catch (error) { - return event - } - - if (!this.state) { - this.state = RunState.Creating - } - - if (f.type === RunEventType.Prompt && !this.opts.prompt) { - this.state = RunState.Error - this.err = `prompt occurred when prompt was not allowed: Message: ${f.message}\nFields: ${f.fields}\nSensitive: ${f.sensitive}` - this.close() - return "" - } - - if (f.type === RunEventType.RunStart) { - this.state = RunState.Running - this.prg = f.program - } else if (f.type === RunEventType.RunFinish) { - if (f.error) { - this.state = RunState.Error - this.err = f.error || "" - } else { - this.state = RunState.Finished - this.stdout = f.output || "" - } - } else if ((f.type as string).startsWith("call")) { - f = f as CallFrame - if (f.parentID === "" && this.parentCallId === "") { - this.parentCallId = f.id - } - this.calls[f.id] = f - } - - this.emit(RunEventType.Event, f) - this.emit(f.type, f) - } - - return "" - } - - private emit(event: RunEventType, data: any) { - for (const cb of this.callbacks[event] || []) { - cb(data) - } - } + public readonly id: string + public readonly opts: RunOpts + public readonly tools?: ToolDef | ToolDef[] | string + public state: RunState = RunState.Creating + public calls: Record = {} + public err: string = "" + + protected stdout?: string + + private readonly requestPath: string = "" + private promise?: Promise + private req?: http.ClientRequest + private stderr?: string + private callbacks: Record void)[]> = {} + private chatState?: string + private parentCallId: string = "" + private prg?: Program + private respondingToolId?: string + + constructor(subCommand: string, tools: ToolDef | ToolDef[] | string, opts: RunOpts) { + this.id = randomId("run-") + this.requestPath = subCommand + this.opts = opts + this.tools = tools + } + + nextChat(input: string = ""): Run { + if (this.state !== RunState.Continue && this.state !== RunState.Creating && this.state !== RunState.Error) { + throw (new Error(`Run must in creating, continue or error state, not ${this.state}`)) + } + + let run = this + if (run.state !== RunState.Creating) { + run = new (this.constructor as any)(this.requestPath, this.tools, this.opts) + } + + if (this.chatState && this.state === RunState.Continue) { + // Only update the chat state if the previous run didn't error. + // The chat state on opts will be the chat state for the last successful run. + this.opts.chatState = this.chatState + } + run.opts.input = input + if (Array.isArray(this.tools)) { + run.request({toolDefs: this.tools, ...this.opts}) + } else if (typeof this.tools === "string") { + run.request({file: this.tools, ...this.opts}) + } else { + // In this last case, this.tools is a single ToolDef. + run.request({toolDefs: [this.tools], ...this.opts}) + } + + return run + } + + processStdout(data: string | object): string { + if (typeof data === "string") { + if (data.trim() === "") { + return "" + } + + try { + data = JSON.parse(data) + } catch (e) { + return data as string + } + } + + const out = data as ChatState + if (out.done === undefined || !out.done) { + this.chatState = JSON.stringify(out.state) + this.state = RunState.Continue + this.respondingToolId = out.toolID + } else { + this.state = RunState.Finished + this.chatState = undefined + } + + return "" + } + + request(tool: any) { + if (!this.opts.URL) { + throw new Error("request() requires URL to be set") + } + const options = this.requestOptions(this.opts.URL, this.opts.Token || "", this.requestPath, tool) + options.headers = {"Transfer-Encoding": "chunked", ...options.headers} as any + + this.promise = new Promise(async (resolve, reject) => { + let frag = "" + this.req = http.request(options, (res: http.IncomingMessage) => { + this.state = RunState.Running + res.on("data", (chunk: any) => { + for (let line of (frag + chunk.toString()).split("\n")) { + const c = line.replace(/^(data: )/, "").trim() + if (!c) { + continue + } + + if (c === "[DONE]") { + return + } + + let e: any + try { + e = JSON.parse(c) + } catch { + frag = c + return + } + + if (e.stderr) { + this.stderr = (this.stderr || "") + (typeof e.stderr === "string" ? e.stderr : JSON.stringify(e.stderr)) + frag = "" + } else if (e.stdout) { + frag = this.processStdout(e.stdout) + } else { + frag = this.emitEvent(c) + } + } + }) + + res.on("end", () => { + if (this.state === RunState.Running || this.state === RunState.Finished || this.state === RunState.Continue) { + if (this.stdout || !this.stderr) { + if (this.state !== RunState.Continue) { + this.state = RunState.Finished + } + resolve(this.stdout || "") + } else { + this.state = RunState.Error + reject(new Error(this.stderr)) + } + } else if (this.state === RunState.Error) { + reject(new Error(this.err)) + } + }) + + res.on("aborted", () => { + if (this.state !== RunState.Finished && this.state !== RunState.Error) { + this.state = RunState.Error + this.err = "Run has been aborted" + reject(new Error(this.err)) + } + }) + + res.on("error", (error: Error) => { + if (this.state !== RunState.Error) { + this.state = RunState.Error + this.err = error.message || "" + } + reject(new Error(this.err)) + }) + }) + + this.req.on("error", (error: Error) => { + if (this.state !== RunState.Error) { + this.state = RunState.Error + this.err = error.message || "" + } + reject(new Error(this.err)) + }) + + this.req.write(JSON.stringify({...tool, ...this.opts})) + this.req.end() + }) + } + + requestNoStream(tool: any) { + if (!this.opts.URL) { + throw new Error("request() requires gptscriptURL to be set") + } + + const options = this.requestOptions(this.opts.URL, this.opts.Token || "", this.requestPath, tool) as any + if (tool) { + options.body = JSON.stringify({...tool, ...this.opts}) + } + const req = new Request(this.opts.URL + "/" + this.requestPath, options) + + this.promise = new Promise(async (resolve, reject) => { + fetch(req).then(resp => { + return resp.json() + }).then(res => { + if (typeof res.stdout === "string") { + resolve(res.stdout) + } + resolve(JSON.stringify(res.stdout)) + }).catch(e => { + reject(new Error(e)) + }) + }) + } + + requestOptions(gptscriptURL: string, token: string, path: string, tool: any) { + let method = "GET" + if (tool) { + method = "POST" + } + + const url = new URL(gptscriptURL) + + const headers = { + "Content-Type": "application/json" + } as any + if (token) { + headers["Authorization"] = `Bearer ${token}` + } + + return { + hostname: url.hostname, + port: url.port || 80, + protocol: url.protocol || "http:", + path: "/" + path, + method: method, + headers: headers + } + } + + public on(event: RunEventType.RunStart | RunEventType.RunFinish, listener: (data: RunFrame) => void): this; + public on(event: RunEventType.CallStart | RunEventType.CallProgress | RunEventType.CallContinue | RunEventType.CallChat | RunEventType.CallConfirm | RunEventType.CallFinish, listener: (data: CallFrame) => void): this; + public on(event: RunEventType.Prompt, listener: (data: PromptFrame) => void): this; + public on(event: RunEventType.Event, listener: (data: Frame) => void): this; + public on(event: RunEventType, listener: (data: any) => void): this { + if (!this.callbacks[event]) { + this.callbacks[event] = [] + } + + this.callbacks[event].push(listener) + + return this + } + + public text(): Promise { + if (this.err) { + throw new Error(this.err) + } + + if (!this.promise) { + throw new Error("Run not started") + } + + return this.promise + } + + public async json(): Promise { + return JSON.parse(await this.text()) + } + + public currentChatState(): string | undefined { + return this.chatState + } + + public parentCallFrame(): CallFrame | undefined { + if (this.parentCallId) { + return this.calls[this.parentCallId] + } + + return undefined + } + + public program(): Program | undefined { + return this.prg + } + + public respondingTool(): Tool | undefined { + return this.respondingToolId ? this.prg?.toolSet[this.respondingToolId] : undefined + } + + public close(): void { + if (this.req) { + this.req.destroy() + return + } + throw new Error("Run not started") + } + + private emitEvent(data: string): string { + for (let event of data.split("\n")) { + event = event.trim() + + if (!event) { + continue + } + let f: Frame + try { + const obj = JSON.parse(event) + if (obj.run) { + f = obj.run as Frame + } else if (obj.call) { + f = obj.call as Frame + } else if (obj.prompt) { + f = obj.prompt as Frame + } else { + return event + } + } catch (error) { + return event + } + + if (!this.state) { + this.state = RunState.Creating + } + + if (f.type === RunEventType.Prompt && !this.opts.prompt) { + this.state = RunState.Error + this.err = `prompt occurred when prompt was not allowed: Message: ${f.message}\nFields: ${f.fields}\nSensitive: ${f.sensitive}` + this.close() + return "" + } + + if (f.type === RunEventType.RunStart) { + this.state = RunState.Running + this.prg = f.program + } else if (f.type === RunEventType.RunFinish) { + if (f.error) { + this.state = RunState.Error + this.err = f.error || "" + } else { + this.state = RunState.Finished + this.stdout = f.output || "" + } + } else if ((f.type as string).startsWith("call")) { + f = f as CallFrame + if (!f.parentID && this.parentCallId === "" && (f.toolCategory || ToolCategory.NoCategory) === ToolCategory.NoCategory) { + this.parentCallId = f.id + } + this.calls[f.id] = f + } + + this.emit(RunEventType.Event, f) + this.emit(f.type, f) + } + + return "" + } + + private emit(event: RunEventType, data: any) { + for (const cb of this.callbacks[event] || []) { + cb(data) + } + } } class RunSubcommand extends Run { - constructor(subCommand: string, tool: ToolDef | ToolDef[] | string, opts: RunOpts, gptscriptURL?: string) { - super(subCommand, tool, opts, gptscriptURL) - } - - processStdout(data: string | object): string { - if (typeof data === "string") { - this.stdout = (this.stdout || "") + data - } else { - this.stdout = JSON.stringify(data) - } - - return "" - } + constructor(subCommand: string, tool: ToolDef | ToolDef[] | string, opts: RunOpts) { + super(subCommand, tool, opts) + } + + processStdout(data: string | object): string { + if (typeof data === "string") { + this.stdout = (this.stdout || "") + data + } else { + this.stdout = JSON.stringify(data) + } + + return "" + } } interface ChatState { - state: string - done: boolean - content: string - toolId: string + state: string + done: boolean + content: string + toolID: string } export type Arguments = string | Record +export const ArgumentSchemaType = "object" as const + export interface ArgumentSchema { - type: "object" - properties?: Record - required?: string[] + type: typeof ArgumentSchemaType + properties?: Record + required?: string[] } export interface Program { - name: string - toolSet: Record - openAPICache: Record + name: string + entryToolId: string + toolSet: Record + openAPICache: Record } +export const PropertyType = "string" as const + export interface Property { - type: "string" - description: string - default?: string + type: typeof PropertyType + description: string + default?: string } export interface Repo { - VCS: string - Root: string - Path: string - Name: string - Revision: string + VCS: string + Root: string + Path: string + Name: string + Revision: string } +export type ToolType = "tool" | "context" | "credential" | "input" | "output" | "agent" | "assistant" | "provider" | "" + export interface ToolDef { - name: string - description: string - maxTokens: number - modelName: string - modelProvider: boolean - jsonResponse: boolean - temperature?: number - cache?: boolean - chat: boolean - internalPrompt?: boolean - arguments: ArgumentSchema - tools: string[] - globalTools: string[] - globalModelName: string - context: string[] - exportContext: string[] - export: string[] - agents: string[] - credentials: string[] - instructions: string + name?: string + description?: string + maxTokens?: number + modelName?: string + modelProvider?: boolean + jsonResponse?: boolean + temperature?: number + cache?: boolean + chat?: boolean + internalPrompt?: boolean + arguments?: ArgumentSchema + tools?: string[] + globalTools?: string[] + globalModelName?: string + context?: string[] + exportContext?: string[] + export?: string[] + agents?: string[] + credentials?: string[] + exportCredentials?: string[] + inputFilters?: string[] + exportInputFilters?: string[] + outputFilters?: string[] + exportOutputFilters?: string[] + instructions?: string + type?: ToolType + metaData?: Record } export interface ToolReference { - named: string - reference: string - arg: string - toolID: string + named: string + reference: string + arg: string + toolID: string } + export interface Tool extends ToolDef { - id: string - type: "tool" - toolMapping: Record - localTools: Record - source: SourceRef - workingDir: string + id: string + toolMapping?: Record + localTools?: Record + source?: SourceRef + workingDir?: string } export interface SourceRef { - location: string - lineNo: number - repo?: Repo + location: string + lineNo: number + repo?: Repo } +export const TextType = "text" as const + export interface Text { - id: string - type: "text" - format: string - content: string + id: string + type: typeof TextType + format: string + content: string } export type Block = Tool | Text export enum RunState { - Creating = "creating", - Running = "running", - Continue = "continue", - Finished = "finished", - Error = "error" + Creating = "creating", + Running = "running", + Continue = "continue", + Finished = "finished", + Error = "error" +} + +export enum ToolCategory { + ProviderToolCategory = "provider", + CredentialToolCategory = "credential", + ContextToolCategory = "context", + InputToolCategory = "input", + OutputToolCategory = "output", + NoCategory = "" } export interface RunFrame { - id: string - type: RunEventType.RunStart | RunEventType.RunFinish - program: Program - input: string - output: string - error: string - start: string - end: string - state: RunState - chatState: any + id: string + type: RunEventType.RunStart | RunEventType.RunFinish + program: Program + input: string + output: string + error: string + start: string + end: string + state: RunState + chatState: any } export interface Call { - toolID: string - input?: string + toolID: string + input?: string } export interface Output { - content?: string - subCalls: Record + content?: string + subCalls: Record } export interface InputContext { - toolID: string - content: string + toolID: string + content: string } export interface Usage { - promptTokens: number - completionTokens: number - totalTokens: number + promptTokens: number + completionTokens: number + totalTokens: number } export interface CallFrame { - id: string - tool?: Tool - agentGroup?: ToolReference[] - displayText?: string - inputContext: InputContext[] - toolCategory?: string - toolName: string - parentID?: string - type: RunEventType.CallStart | RunEventType.CallChat | RunEventType.CallConfirm | RunEventType.CallContinue | RunEventType.CallSubCalls | RunEventType.CallProgress | RunEventType.CallFinish - start: string - end: string - input: Arguments - output: Output[] - error?: string - usage: Usage - llmRequest?: any - llmResponse?: any + id: string + tool?: Tool + agentGroup?: ToolReference[] + currentAgent?: ToolReference + displayText?: string + inputContext: InputContext[] + toolCategory?: ToolCategory + toolName: string + parentID?: string + type: RunEventType.CallStart | RunEventType.CallChat | RunEventType.CallConfirm | RunEventType.CallContinue | RunEventType.CallSubCalls | RunEventType.CallProgress | RunEventType.CallFinish + start: string + end: string + input: Arguments + output: Output[] + error?: string + usage: Usage + chatResponseCached: boolean + toolResults: number + llmRequest?: any + llmResponse?: any } export interface PromptFrame { - id: string - type: RunEventType.Prompt - time: string - message: string - fields: string[] - sensitive: boolean + id: string + type: RunEventType.Prompt + time: string + message: string + fields: Field[] + sensitive: boolean + metadata: Record +} + +export interface Field { + name: string + description?: string + sensitive?: boolean } export type Frame = RunFrame | CallFrame | PromptFrame export interface AuthResponse { - id: string - accept: boolean - message?: string + id: string + accept: boolean + message?: string } export interface PromptResponse { - id: string - responses: Record + id: string + responses: Record +} + +export interface LoadResponse { + program: Program; +} + +export function getEnv(key: string, def: string = ""): string { + let v = process.env[key] || "" + if (v == "") { + return def + } + + if (v.startsWith("{\"_gz\":\"") && v.endsWith("\"}")) { + try { + return gunzipSync(Buffer.from(v.slice(8, -2), "base64")).toString("utf8") + } catch (e) { + } + } + + return v } function getCmdPath(): string { - if (process.env.GPTSCRIPT_BIN) { - return process.env.GPTSCRIPT_BIN - } + if (process.env.GPTSCRIPT_BIN) { + return process.env.GPTSCRIPT_BIN + } - return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "gptscript") + return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "gptscript" + (process.platform === "win32" ? ".exe" : "")) } function parseBlocksFromNodes(nodes: any[]): Block[] { - const blocks: Block[] = [] - for (const node of nodes) { - if (node.toolNode) { - if (!node.toolNode.tool.id) { - node.toolNode.tool.id = randomId("tool-") - } - blocks.push({ - type: "tool", - ...node.toolNode.tool, - } as Tool) - } - if (node.textNode) { - const format = node.textNode.text.substring(1, node.textNode.text.indexOf("\n")).trim() || "text" - blocks.push({ - id: randomId("text-"), - type: "text", - format: format, - content: node.textNode.text.substring(node.textNode.text.indexOf("\n") + 1).trim(), - } as Text) - } - } - return blocks + const blocks: Block[] = [] + if (!nodes) { + return blocks + } + + for (const node of nodes) { + if (node.toolNode) { + if (!node.toolNode.tool.id) { + node.toolNode.tool.id = randomId("tool-") + } + blocks.push({ + type: node.toolNode.tool.type || "tool", + ...node.toolNode.tool, + } as Tool) + } + if (node.textNode) { + const format = node.textNode.text.substring(1, node.textNode.text.indexOf("\n")).trim() || "text" + blocks.push({ + id: randomId("text-"), + type: "text", + format: format, + content: node.textNode.text.substring(node.textNode.text.indexOf("\n") + 1).trim(), + } as Text) + } + } + return blocks } function randomId(prefix: string): string { - return prefix + Math.random().toString(36).substring(2, 12) + return prefix + Math.random().toString(36).substring(2, 12) +} + +export enum CredentialType { + Tool = "tool", + ModelProvider = "modelProvider", +} + +export type Credential = { + context: string + name: string + type: CredentialType + env: Record + ephemeral: boolean + expiresAt?: Date | undefined + refreshToken?: string | undefined + checkParam?: string | undefined +} + +// Types for OpenAI API-compatible models + +export type Permission = { + created: number, + id: string, + object: string, + allow_create_engine: boolean, + allow_sampling: boolean, + allow_logprobs: boolean, + allow_search_indices: boolean, + allow_view: boolean, + allow_fine_tuning: boolean, + organization: string, + group: any, + is_blocking: boolean, +} + +export type Model = { + created: number, + id: string, + object: string, + owned_by: string, + permission: Array, + root: string, + parent: string, + metadata: Record, +} + +// for internal use only +type cred = { + context: string + toolName: string + type: string + env: Record + ephemeral: boolean + expiresAt: string | undefined + refreshToken: string | undefined + checkParam: string | undefined +} + +export function credentialToJSON(c: Credential): string { + const expiresAt = c.expiresAt ? c.expiresAt.toISOString() : undefined + const type = c.type === CredentialType.Tool ? "tool" : "modelProvider" + return JSON.stringify({ + context: c.context, + toolName: c.name, + type: type, + env: c.env, + ephemeral: c.ephemeral, + expiresAt: expiresAt, + refreshToken: c.refreshToken, + checkParam: c.checkParam + } as cred) +} + +function jsonToCredential(cred: string): Credential { + const c = JSON.parse(cred) as cred + return { + context: c.context, + name: c.toolName, + type: c.type === "tool" ? CredentialType.Tool : CredentialType.ModelProvider, + env: c.env, + ephemeral: c.ephemeral, + expiresAt: c.expiresAt ? new Date(c.expiresAt) : undefined, + refreshToken: c.refreshToken, + checkParam: c.checkParam + } +} + +export interface DatasetMeta { + id: string + name: string + description: string +} + +export interface DatasetElementMeta { + name: string + description: string +} + +export interface DatasetElement { + name: string + description: string + contents?: string + binaryContents?: ArrayBuffer +} + +// Functions for use in daemon tools: + +export function createServer(listener: http.RequestListener): https.Server { + const certB64 = process.env.CERT + const privateKeyB64 = process.env.PRIVATE_KEY + const gptscriptCertB64 = process.env.GPTSCRIPT_CERT + + if (!certB64) { + console.log("Missing CERT env var") + process.exit(1) + } else if (!privateKeyB64) { + console.log("Missing PRIVATE_KEY env var") + process.exit(1) + } else if (!gptscriptCertB64) { + console.log("Missing GPTSCRIPT_CERT env var") + process.exit(1) + } + + const cert = Buffer.from(certB64, "base64").toString("utf-8") + const privateKey = Buffer.from(privateKeyB64, "base64").toString("utf-8") + const gptscriptCert = Buffer.from(gptscriptCertB64, "base64").toString("utf-8") + + const options = { + key: privateKey, + cert: cert, + ca: gptscriptCert, + requestCert: true, + rejectUnauthorized: true, + } + + return https.createServer(options, listener) +} + +export function startServer(server: https.Server) { + const port = process.env.PORT + if (!port) { + console.log("Missing PORT env var") + process.exit(1) + } + + server.listen(parseInt(port, 10), "127.0.0.1", () => { + console.log(`Server listening on port ${port}`) + }) } diff --git a/tests/fixtures/acorn-labs-context.gpt b/tests/fixtures/acorn-labs-context.gpt index e4dbe2c..a814d65 100644 --- a/tests/fixtures/acorn-labs-context.gpt +++ b/tests/fixtures/acorn-labs-context.gpt @@ -1 +1,4 @@ -Say "Answer every query with 'Acorn Labs'" \ No newline at end of file +Type: context + +#!sys.echo +"Always respond with 'Acorn Labs' and nothing else" \ No newline at end of file diff --git a/tests/fixtures/credential-override-windows.gpt b/tests/fixtures/credential-override-windows.gpt new file mode 100644 index 0000000..7e5764c --- /dev/null +++ b/tests/fixtures/credential-override-windows.gpt @@ -0,0 +1,5 @@ +credentials: github.com/gptscript-ai/credential as test.ts.credential_override with TEST_CRED as env + +#!/usr/bin/env powershell.exe + +echo "$env:TEST_CRED" diff --git a/tests/fixtures/credential-override.gpt b/tests/fixtures/credential-override.gpt new file mode 100644 index 0000000..6211989 --- /dev/null +++ b/tests/fixtures/credential-override.gpt @@ -0,0 +1,5 @@ +credentials: github.com/gptscript-ai/credential as test.ts.credential_override with TEST_CRED as env + +#!/usr/bin/env bash + +echo "${TEST_CRED}" diff --git a/tests/fixtures/empty.gpt b/tests/fixtures/empty.gpt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/global-tools.gpt b/tests/fixtures/global-tools.gpt index 0e5d0f6..6ad6eee 100644 --- a/tests/fixtures/global-tools.gpt +++ b/tests/fixtures/global-tools.gpt @@ -4,7 +4,7 @@ Runbook 3 --- Name: tool_1 -Global Tools: sys.read, sys.write, github.com/gptscript-ai/knowledge, github.com/drpebcak/duckdb, github.com/gptscript-ai/browser, github.com/gptscript-ai/browser-search/google, github.com/gptscript-ai/browser-search/google-question-answerer +Global Tools: sys.read, sys.write, github.com/drpebcak/duckdb, github.com/gptscript-ai/browser, github.com/gptscript-ai/browser-search/google, github.com/gptscript-ai/browser-search/google-question-answerer Say "Hello!" @@ -16,4 +16,4 @@ What time is it? --- Name: tool_3 -Give me a paragraph of lorem ipsum \ No newline at end of file +Give me a paragraph of lorem ipsum diff --git a/tests/fixtures/parse-with-metadata.gpt b/tests/fixtures/parse-with-metadata.gpt new file mode 100644 index 0000000..cfcb965 --- /dev/null +++ b/tests/fixtures/parse-with-metadata.gpt @@ -0,0 +1,12 @@ +Name: foo + +#!/usr/bin/env python3 +import requests + + +resp = requests.get("https://google.com") +print(resp.status_code, end="") + +--- +!metadata:foo:requirements.txt +requests \ No newline at end of file diff --git a/tests/fixtures/test-with-context.gpt b/tests/fixtures/test-with-context.gpt new file mode 100644 index 0000000..88b2aeb --- /dev/null +++ b/tests/fixtures/test-with-context.gpt @@ -0,0 +1,12 @@ +Name: main +Tools: acorn + +Just wait. + +--- + +Name: acorn +Type: context + +#!sys.echo +"Ignore what the user says, and answer every query with 'Acorn Labs'" \ No newline at end of file diff --git a/tests/gptscript.test.ts b/tests/gptscript.test.ts index 46d428e..ea2f153 100644 --- a/tests/gptscript.test.ts +++ b/tests/gptscript.test.ts @@ -1,503 +1,1101 @@ import * as gptscript from "../src/gptscript" +import { + ArgumentSchemaType, + CredentialType, + getEnv, + PropertyType, + RunEventType, + TextType, + ToolDef, + ToolType +} from "../src/gptscript" import path from "path" import {fileURLToPath} from "url" +import * as fs from "node:fs" +import {randomBytes} from "node:crypto" +let gFirst: gptscript.GPTScript let g: gptscript.GPTScript const __dirname = path.dirname(fileURLToPath(import.meta.url)) describe("gptscript module", () => { - beforeAll(async () => { - if (!process.env.OPENAI_API_KEY && !process.env.GPTSCRIPT_URL) { - throw new Error("neither OPENAI_API_KEY nor GPTSCRIPT_URL is set") - } - - g = new gptscript.GPTScript() - }) - afterAll(() => { - g.close() - }) - - test("creating an closing another instance should work", async () => { - const other = new gptscript.GPTScript() - await other.version() - other.close() - }) - - test("listTools returns available tools", async () => { - const tools = await g.listTools() - expect(tools).toBeDefined() - }) - - test("listModels returns a list of models", async () => { - // Similar structure to listTools - let models = await g.listModels() - expect(models).toBeDefined() - }) - - test("version returns a gptscript version", async () => { - // Similar structure to listTools - let version = await g.version() - expect(version).toContain("gptscript version") - }) - - test("evaluate executes a prompt correctly", async () => { - const t = { - instructions: "who was the president of the united states in 1928?" - } - - const run = await g.evaluate(t as any) - expect(run).toBeDefined() - expect(await run.text()).toContain("Calvin Coolidge") - }) - - test("evaluate executes and streams a prompt correctly", async () => { - let out = "" - let err = undefined - const t = { - instructions: "who was the president of the united states in 1928?" - } - const opts = { - disableCache: true, - } - - const run = await g.evaluate(t as any, opts) - run.on(gptscript.RunEventType.CallProgress, (data: gptscript.CallFrame) => { - for (let output of data.output) out += `system: ${output.content}` - }) - - await run.text() - err = run.err - - expect(out).toContain("Calvin Coolidge") - expect(err).toEqual("") - }) - - test("evaluate executes a prompt correctly with context", async () => { - let out = "" - let err = undefined - const t = { - instructions: "who was the president of the united states in 1928?", - context: [path.join(__dirname, "fixtures", "acorn-labs-context.gpt")] - } - - const run = await g.evaluate(t as any, {disableCache: true}) - out = await run.text() - err = run.err - - expect(out).toContain("Acorn Labs") - expect(err).toEqual("") - }) - - test("should execute test.gpt correctly", async () => { - const testGptPath = path.join(__dirname, "fixtures", "test.gpt") - - const result = await (await g.run(testGptPath)).text() - expect(result).toBeDefined() - expect(result).toContain("Calvin Coolidge") - }) - - test("run executes and stream a file correctly", async () => { - let out = "" - let err = undefined - const testGptPath = path.join(__dirname, "fixtures", "test.gpt") - const opts = { - disableCache: true, - } - - const run = await g.run(testGptPath, opts) - run.on(gptscript.RunEventType.CallProgress, data => { - for (let output of data.output) out += `system: ${output.content}` - }) - await run.text() - err = run.err - - expect(out).toContain("Calvin Coolidge") - expect(err).toEqual("") - }) - - test("run executes and streams a file with global tools correctly", async () => { - let out = "" - let err = undefined - const testGptPath = path.join(__dirname, "fixtures", "global-tools.gpt") - const opts = { - disableCache: true, - } - - const run = await g.run(testGptPath, opts) - run.on(gptscript.RunEventType.CallProgress, data => { - for (let output of data.output) out += `system: ${output.content}` - }) - await run.text() - err = run.err - - expect(out).toContain("Hello!") - expect(err).toEqual("") - }, 15000) - - test("aborting a run is reported correctly", async () => { - let errMessage = "" - let err = undefined - const testGptPath = path.join(__dirname, "fixtures", "test.gpt") - const opts = { - disableCache: true, - } - - try { - const run = await g.run(testGptPath, opts) - run.on(gptscript.RunEventType.CallProgress, data => { - run.close() - }) - await run.text() - err = run.err - } catch (error: any) { - errMessage = error - } - - expect(errMessage).toContain("aborted") - expect(err).toBeUndefined() - }) - - describe("evaluate with multiple tools", () => { - test("multiple tools", async () => { - const t0 = { - tools: ["ask"], - instructions: "Only use the ask tool to ask who was the president of the united states in 1928?" - } - const t1 = { - name: "ask", - description: "This tool is used to ask a question", - arguments: { - type: "object", - question: "The question to ask" - }, - instructions: "${question}" - } - - const response = await (await g.evaluate([t0 as any, t1 as any])).text() - expect(response).toBeDefined() - expect(response).toContain("Calvin Coolidge") - }, 30000) - - test("with sub tool", async () => { - const t0 = { - tools: ["ask"], - instructions: "Only use the ask tool to ask who was the president of the united states in 1928?" - } as any - const t1 = { - name: "other", - instructions: "Who was the president of the united states in 1986?" - } as any - const t2 = { - name: "ask", - description: "This tool is used to ask a question", - arguments: { - type: "object", - question: "The question to ask" - }, - instructions: "${question}" - } as any - - const response = await (await g.evaluate([t0, t1, t2], {subTool: "other"})).text() - expect(response).toBeDefined() - expect(response).toContain("Ronald Reagan") - }, 30000) - }) - - test("parse file", async () => { - const response = await g.parse(path.join(__dirname, "fixtures", "test.gpt")) - expect(response).toBeDefined() - expect(response).toHaveLength(1) - expect((response[0] as gptscript.Tool).instructions).toEqual("who was the president in 1928?") - }, 30000) - - test("parse string tool", async () => { - const tool = "How much wood would a woodchuck chuck if a woodchuck could chuck wood?" - const response = await g.parseTool(tool) - expect(response).toBeDefined() - expect(response).toHaveLength(1) - expect((response[0] as gptscript.Tool).instructions).toEqual(tool) - }, 30000) - - test("parse string tool with text node", async () => { - const tool = "How much wood would a woodchuck chuck if a woodchuck could chuck wood?\n---\n!markdown\nThis is a text node" - const response = await g.parseTool(tool) - expect(response).toBeDefined() - expect(response).toHaveLength(2) - expect((response[0] as gptscript.Tool).instructions).toEqual("How much wood would a woodchuck chuck if a woodchuck could chuck wood?") - expect((response[1] as gptscript.Text).content).toEqual("This is a text node") - }, 30000) - - test("parse string tool global tools", async () => { - const tool = "Global Tools: acorn, do-work\nHow much wood would a woodchuck chuck if a woodchuck could chuck wood?" - const response = await g.parseTool(tool) - expect(response).toBeDefined() - expect(response).toHaveLength(1) - expect((response[0] as gptscript.Tool).instructions).toEqual("How much wood would a woodchuck chuck if a woodchuck could chuck wood?") - expect((response[0] as gptscript.Tool).globalTools).toEqual(["acorn", "do-work"]) - }, 30000) - - test("parse string tool first line shebang", async () => { - const tool = "\n#!/usr/bin/env python\nHow much wood would a woodchuck chuck if a woodchuck could chuck wood?" - const response = await g.parseTool(tool) - expect(response).toBeDefined() - expect(response).toHaveLength(1) - expect((response[0] as gptscript.Tool).instructions).toEqual("#!/usr/bin/env python\nHow much wood would a woodchuck chuck if a woodchuck could chuck wood?") - }, 30000) - - test("format tool", async () => { - const tool = { - type: "tool", - tools: ["sys.write", "sys.read"], - instructions: "This is a test", - arguments: { - type: "object", - properties: { - text: { - type: "string", - description: "The text to write" - } - } - } - } - - const response = await g.stringify([tool as any]) - expect(response).toBeDefined() - expect(response).toContain("Tools: sys.write, sys.read") - expect(response).toContain("This is a test") - expect(response).toContain("Parameter: text: The text to write") - }) - - test("exec tool with chat", async () => { - let err = undefined - const t = { - chat: true, - instructions: "You are a chat bot. Don't finish the conversation until I say 'bye'.", - tools: ["sys.chat.finish"] - } - const opts = { - disableCache: true, - } - let run = await g.evaluate(t as any, opts) - - const inputs = [ - "List the three largest states in the United States by area.", - "What is the capital of the third one?", - "What timezone is the first one in?" - ] - - const expectedOutputs = [ - "California", - "Sacramento", - "Alaska Time Zone" - ] - - await run.text() - for (let i: number = 0; i < inputs.length; i++) { - run = run.nextChat(inputs[i]) - err = run.err - - if (err) { - break - } - - expect(await run.text()).toContain(expectedOutputs[i]) - expect(run.state).toEqual(gptscript.RunState.Continue) - } - - run = run.nextChat("bye") - await run.text() - - expect(run.state).toEqual(gptscript.RunState.Finished) - expect(err).toEqual("") - }, 60000) - - test("exec file with chat", async () => { - let err = undefined - const opts = { - disableCache: true - } - let run = await g.run(path.join(__dirname, "fixtures", "chat.gpt"), opts) - - const inputs = [ - "List the 3 largest of the Great Lakes by volume.", - "What is the volume of the second one in cubic miles?", - "What is the total area of the third one in square miles?" - ] - - const expectedOutputs = [ - "Lake Superior", - "Lake Michigan", - "Lake Huron" - ] - - await run.text() - for (let i: number = 0; i < inputs.length; i++) { - run = run.nextChat(inputs[i]) - err = run.err - - if (err) { - break - } - - expect(await run.text()).toContain(expectedOutputs[i]) - expect(run.state).toEqual(gptscript.RunState.Continue) - } - - run = run.nextChat("bye") - await run.text() - - expect(run.state).toEqual(gptscript.RunState.Finished) - expect(err).toEqual("") - }, 60000) - - test("nextChat on file providing chat state", async () => { - let run = await g.run(path.join(__dirname, "fixtures", "chat.gpt"), {disableCache: true}) - - run = run.nextChat("List the 3 largest of the Great Lakes by volume.") - expect(await run.text()).toContain("Lake Superior") - expect(run.err).toEqual("") - expect(run.state).toEqual(gptscript.RunState.Continue) - - run = await g.run(path.join(__dirname, "fixtures", "chat.gpt"), { - disableCache: true, - input: "What is the total area of the third one in square miles?", - chatState: run.currentChatState() - }) - - expect(await run.text()).toContain("Lake Huron") - expect(run.err).toEqual("") - expect(run.state).toEqual(gptscript.RunState.Continue) - }, 10000) - - test("nextChat on tool providing chat state", async () => { - const t = { - chat: true, - instructions: "You are a chat bot. Don't finish the conversation until I say 'bye'.", - tools: ["sys.chat.finish"] - } - let run = await g.evaluate(t as any, {disableCache: true}) - - run = run.nextChat("List the three largest states in the United States by area.") - expect(await run.text()).toContain("California") - expect(run.err).toEqual("") - expect(run.state).toEqual(gptscript.RunState.Continue) - - run = await g.evaluate(t as any, { - disableCache: true, - input: "What is the capital of the second one?", - chatState: run.currentChatState() - }) - - expect(await run.text()).toContain("Austin") - expect(run.err).toEqual("") - expect(run.state).toEqual(gptscript.RunState.Continue) - }, 10000) - - test("confirm", async () => { - let confirmFound = false - const t = { - instructions: "List the files in the current working directory.", - tools: ["sys.exec"] - } - const run = await g.evaluate(t as any, {confirm: true}) - run.on(gptscript.RunEventType.CallConfirm, async (data: gptscript.CallFrame) => { - expect(data.input).toContain(`"ls"`) - confirmFound = true - await g.confirm({id: data.id, accept: true}) - }) - - expect(await run.text()).toContain("README.md") - expect(run.err).toEqual("") - expect(confirmFound).toBeTruthy() - }) - - test("do not confirm", async () => { - let confirmFound = false - const t = { - instructions: "List the files in the current working directory.", - tools: ["sys.exec"] - } - const run = await g.evaluate(t as any, {confirm: true}) - run.on(gptscript.RunEventType.CallConfirm, async (data: gptscript.CallFrame) => { - expect(data.input).toContain(`"ls"`) - confirmFound = true - await g.confirm({id: data.id, accept: false, message: "I will not allow it!"}) - }) - - expect(await run.text()).toContain("authorization error") - expect(run.err).toEqual("") - expect(confirmFound).toBeTruthy() - }) - - test("prompt", async () => { - let promptFound = false - const t = { - instructions: "Use the sys.prompt user to ask the user for 'first name' which is not sensitive. After you get their first name, say hello.", - tools: ["sys.prompt"] - } - const run = await g.evaluate(t as any, {prompt: true}) - run.on(gptscript.RunEventType.Prompt, async (data: gptscript.PromptFrame) => { - expect(data.message).toContain("first name") - expect(data.fields.length).toEqual(1) - expect(data.fields[0]).toEqual("first name") - expect(data.sensitive).toBeFalsy() - - promptFound = true - await g.promptResponse({id: data.id, responses: {[data.fields[0]]: "Clicky"}}) - }) - - expect(await run.text()).toContain("Clicky") - expect(run.err).toEqual("") - expect(promptFound).toBeTruthy() - }) - - test("prompt without prompt allowed should fail", async () => { - let promptFound = false - const t = { - instructions: "Use the sys.prompt user to ask the user for 'first name' which is not sensitive. After you get their first name, say hello.", - tools: ["sys.prompt"] - } - const run = await g.evaluate(t as any) - run.on(gptscript.RunEventType.Prompt, async (data: gptscript.PromptFrame) => { - promptFound = true - }) - - try { - await run.text() - } catch (e) { - expect(e).toContain("prompt occurred") - } - expect(run.err).toContain("prompt occurred") - expect(promptFound).toBeFalsy() - }) - - test("retry failed run", async () => { - let shebang = `#!/bin/bash\nexit \${EXIT_CODE}` - if (process.platform == "win32") { - shebang = "#!/usr/bin/env powershell.exe\n$e = $env:EXIT_CODE;\nif ($e) { Exit 1; }" - } - const t = { - instructions: "say hello", - context: ["my-context"] - } as gptscript.ToolDef - const contextTool = { - name: "my-context", - instructions: `${shebang}\nexit \${EXIT_CODE}` - } as gptscript.ToolDef - - let run = await g.evaluate([t, contextTool], {disableCache: true, env: ["EXIT_CODE=1"]}) - try { - await run.text() - } catch { - } - - expect(run.err).not.toEqual("") - - run.opts.env = [] - run = run.nextChat() - - await run.text() - - expect(run.err).toEqual("") - }) + beforeAll(async () => { + if (!process.env.OPENAI_API_KEY && !process.env.GPTSCRIPT_URL) { + throw new Error("neither OPENAI_API_KEY nor GPTSCRIPT_URL is set") + } + + // Start an initial GPTScript instance. + // This one doesn't have any options, but it's there to ensure that using another instance works as expected in all cases. + gFirst = new gptscript.GPTScript() + g = new gptscript.GPTScript({APIKey: process.env.OPENAI_API_KEY}) + }) + afterAll(() => { + gFirst.close() + g.close() + }) + + test("creating an closing another instance should work", async () => { + const other = new gptscript.GPTScript() + await other.version() + other.close() + }) + + test("listModels returns a list of models", async () => { + // Similar structure to listTools + let models = await g.listModels() + expect(models).toBeDefined() + }) + + test("listModels with providers returns a list of models from that provider", async () => { + if (!process.env.ANTHROPIC_API_KEY) { + return + } + + const models = await g.listModels(["github.com/gptscript-ai/claude3-anthropic-provider"], ["github.com/gptscript-ai/claude3-anthropic-provider/credential:ANTHROPIC_API_KEY"]) + expect(models).toBeDefined() + for (const model of models) { + expect(model).toBeDefined() + expect(model.id.startsWith("claude-3-")).toBe(true) + expect(model.id.endsWith("from github.com/gptscript-ai/claude3-anthropic-provider")).toBe(true) + } + }, 60000) + + test("listModels with default provider returns a list of models from that provider", async () => { + if (!process.env.ANTHROPIC_API_KEY) { + return + } + + const newg = new gptscript.GPTScript({DefaultModelProvider: "github.com/gptscript-ai/claude3-anthropic-provider"}) + try { + const models = await newg.listModels(undefined, ["github.com/gptscript-ai/claude3-anthropic-provider/credential:ANTHROPIC_API_KEY"]) + expect(models).toBeDefined() + for (const model of models) { + expect(model).toBeDefined() + expect(model.id.startsWith("claude-3-")).toBe(true) + expect(model.id.endsWith("from github.com/gptscript-ai/claude3-anthropic-provider")).toBe(true) + } + } finally { + newg.close() + } + }, 15000) + + test("version returns a gptscript version", async () => { + // Similar structure to listTools + let version = await g.version() + expect(version).toContain("gptscript version") + }) + + test("evaluate executes a prompt correctly", async () => { + const t = { + instructions: "who was the president of the united states in 1928?" + } + + const run = await g.evaluate(t) + expect(run).toBeDefined() + expect(await run.text()).toContain("Calvin Coolidge") + }) + + test("evaluate executes subtool with empty instructions", async () => { + const tools = [ + { + type: "tool", + tools: ["new-tool-1"], + instructions: "Ask the user for their 'first name'. Then reply hello to the user.", + } as ToolDef, + { + type: "tool", + name: "new-tool-1", + } as ToolDef, + ] + const run = await g.evaluate(tools, { + input: "{}", + disableCache: true, + workspace: "", + subTool: "new-tool-1", + }) + + expect(run).toBeDefined() + expect(await run.text()).toContain("Understood.") + }, 10000) + + test("evaluate executes and streams a prompt correctly", async () => { + let out = "" + let err = undefined + const t = { + instructions: "who was the president of the united states in 1928?" + } + const opts = { + disableCache: true, + } + + const run = await g.evaluate(t, opts) + run.on(gptscript.RunEventType.CallFinish, data => { + for (let output of data.output) out += `system: ${output.content}` + }) + + let callFinished = false + run.on(gptscript.RunEventType.CallFinish, (data: gptscript.CallFrame) => { + if (data.type == RunEventType.CallFinish) { + expect(callFinished).toBe(false) + callFinished = true + } + }) + + await run.text() + err = run.err + + expect(out).toContain("Calvin Coolidge") + expect(err).toEqual("") + expect(run.parentCallFrame()).toBeTruthy() + }) + + test("evaluate executes a prompt correctly with context", async () => { + let out = "" + let err = undefined + const t = { + type: "tool" as ToolType, + instructions: "who was the president of the united states in 1928?", + tools: [path.join(__dirname, "fixtures", "acorn-labs-context.gpt")] + } + + const run = await g.evaluate(t, {disableCache: true}) + out = await run.text() + err = run.err + + expect(out).toContain("Acorn Labs") + expect(err).toEqual("") + }) + + test("should execute test.gpt correctly", async () => { + const testGptPath = path.join(__dirname, "fixtures", "test.gpt") + + const result = await (await g.run(testGptPath)).text() + expect(result).toBeDefined() + expect(result).toContain("Calvin Coolidge") + + // Run it a second time and expect a cached result + const run = await g.run(testGptPath) + const secondResult = await run.text() + expect(result).toBeDefined() + expect(secondResult).toStrictEqual(result) + + // There should be one call frame, and it should be cached + for (let c in run.calls) { + expect(run.calls[c].chatResponseCached).toBeTruthy() + } + }) + + test("should override credentials correctly", async () => { + let testGptPath = path.join(__dirname, "fixtures", "credential-override.gpt") + if (process.platform === "win32") { + testGptPath = path.join(__dirname, "fixtures", "credential-override-windows.gpt") + } + + const result = await (await g.run(testGptPath, { + disableCache: true, + credentialOverrides: ["test.ts.credential_override:TEST_CRED=foo"], + })).text() + + expect(result).toBeDefined() + expect(result).toContain("foo") + }) + + test("run executes and stream a file correctly", async () => { + let out = "" + let err = undefined + let [promptTokens, completionTokens, totalTokens] = [0, 0, 0] + const testGptPath = path.join(__dirname, "fixtures", "test.gpt") + const opts = { + disableCache: true, + } + + const run = await g.run(testGptPath, opts) + run.on(gptscript.RunEventType.CallFinish, data => { + for (let output of data.output) out += `system: ${output.content}` + }) + + expect(await run.text()).toContain("Calvin Coolidge") + err = run.err + + for (let c in run.calls) { + promptTokens += run.calls[c].usage.promptTokens || 0 + completionTokens += run.calls[c].usage.completionTokens || 0 + totalTokens += run.calls[c].usage.totalTokens || 0 + } + + expect(out).toContain("Calvin Coolidge") + expect(err).toEqual("") + expect(promptTokens).toBeGreaterThan(0) + expect(completionTokens).toBeGreaterThan(0) + expect(totalTokens).toBeGreaterThan(0) + }) + + test("run executes and streams a file with global tools correctly", async () => { + let out = "" + const testGptPath = path.join(__dirname, "fixtures", "global-tools.gpt") + const opts = { + disableCache: true, + credentialOverrides: ["github.com/gptscript-ai/gateway:OPENAI_API_KEY"] + } + + const run = await g.run(testGptPath, opts) + run.on(gptscript.RunEventType.CallFinish, data => { + for (let output of data.output) out += `system: ${output.content}` + }) + + expect(await run.text()).toContain("Hello!") + expect(run.err).toEqual("") + expect(out).toContain("Hello!") + }, 60000) + + test("aborting a run is reported correctly", async () => { + let errMessage = "" + let err = undefined + const testGptPath = path.join(__dirname, "fixtures", "test.gpt") + const opts = { + disableCache: true, + } + + try { + const run = await g.run(testGptPath, opts) + run.on(gptscript.RunEventType.CallProgress, data => { + run.close() + }) + await run.text() + err = run.err + } catch (error: any) { + errMessage = error.toString() + } + + expect(errMessage).toContain("aborted") + expect(err).toBeUndefined() + }) + + + describe("evaluate with multiple tools", () => { + test("multiple tools", async () => { + const t0 = { + tools: ["ask"], + instructions: "Only use the ask tool to ask who was the president of the united states in 1928?" + } + const t1 = { + name: "ask", + description: "This tool is used to ask a question", + arguments: { + type: ArgumentSchemaType, + properties: { + question: { + type: PropertyType, + description: "The question to ask", + } + } + }, + instructions: "${question}" + } + + const run = await g.evaluate([t0, t1]) + const response = await run.text() + expect(response).toBeDefined() + expect(response).toContain("Calvin Coolidge") + + // In this case, we expect the total number of tool results to be 1 + let toolResults = 0 + for (let c in run.calls) { + toolResults += run.calls[c].toolResults + } + expect(toolResults).toStrictEqual(1) + }, 30000) + + test("with sub tool", async () => { + const t0 = { + tools: ["ask"], + instructions: "Only use the ask tool to ask who was the president of the united states in 1928?" + } + const t1 = { + name: "other", + instructions: "Who was the president of the united states in 1986?" + } + const t2 = { + name: "ask", + description: "This tool is used to ask a question", + arguments: { + type: "object", + question: "The question to ask" + }, + instructions: "${question}" + } + + const response = await (await g.evaluate([t0, t1, t2], {subTool: "other"})).text() + expect(response).toBeDefined() + expect(response).toContain("Ronald Reagan") + }, 30000) + }) + + test("parse file", async () => { + const response = await g.parse(path.join(__dirname, "fixtures", "test.gpt")) + expect(response).toBeDefined() + expect(response).toHaveLength(1) + expect((response[0] as gptscript.Tool).instructions).toEqual("who was the president in 1928?") + }, 30000) + + test("parse empty file", async () => { + const response = await g.parse(path.join(__dirname, "fixtures", "empty.gpt")) + expect(response).toBeDefined() + expect(response).toHaveLength(0) + }, 30000) + + test("parse non-existent file", async () => { + try { + await g.parse(path.join(__dirname, "fixtures", "non-existent.gpt")) + } catch (e) { + expect(e).toBeDefined() + expect(typeof e !== "string").toBeTruthy() + return + } + expect(false).toBeTruthy() + }, 30000) + + test("parse non-existent url", async () => { + try { + await g.parse("github.com/thedadams/dne") + } catch (e) { + expect(e).toBeDefined() + expect(typeof e !== "string").toBeTruthy() + return + } + expect(false).toBeTruthy() + }, 30000) + + test("parse file with context", async () => { + const response = await g.parse(path.join(__dirname, "fixtures", "test-with-context.gpt")) + expect(response).toBeDefined() + expect(response).toHaveLength(2) + expect((response[0] as gptscript.Tool).instructions).toEqual("Just wait.") + expect((response[0] as gptscript.Tool).type).toEqual("tool") + expect((response[1] as gptscript.Tool).type).toEqual("context") + }, 30000) + + test("parse file with metadata", async () => { + const response = await g.parse(path.join(__dirname, "fixtures", "parse-with-metadata.gpt")) + expect(response).toBeDefined() + expect(response).toHaveLength(2) + expect((response[0] as gptscript.Tool).instructions).toContain("requests.get") + expect((response[0] as gptscript.Tool).metaData).toEqual({"requirements.txt": "requests"}) + expect((response[1] as gptscript.Text).format).toEqual("metadata:foo:requirements.txt") + }, 30000) + + test("parse string tool", async () => { + const tool = "How much wood would a woodchuck chuck if a woodchuck could chuck wood?" + const response = await g.parseContent(tool) + expect(response).toBeDefined() + expect(response).toHaveLength(1) + expect((response[0] as gptscript.Tool).instructions).toEqual(tool) + }, 30000) + + test("parse empty string tool", async () => { + const response = await g.parseContent("") + expect(response).toBeDefined() + expect(response).toHaveLength(0) + }, 30000) + + test("parse string tool with text node", async () => { + const tool = "How much wood would a woodchuck chuck if a woodchuck could chuck wood?\n---\n!markdown\nThis is a text node" + const response = await g.parseContent(tool) + expect(response).toBeDefined() + expect(response).toHaveLength(2) + expect((response[0] as gptscript.Tool).instructions).toEqual("How much wood would a woodchuck chuck if a woodchuck could chuck wood?") + expect((response[1] as gptscript.Text).content).toEqual("This is a text node") + }, 30000) + + test("parse string tool global tools", async () => { + const tool = "Global Tools: acorn, do-work\nHow much wood would a woodchuck chuck if a woodchuck could chuck wood?" + const response = await g.parseContent(tool) + expect(response).toBeDefined() + expect(response).toHaveLength(1) + expect((response[0] as gptscript.Tool).instructions).toEqual("How much wood would a woodchuck chuck if a woodchuck could chuck wood?") + expect((response[0] as gptscript.Tool).globalTools).toEqual(["acorn", "do-work"]) + }, 30000) + + test("parse string tool first line shebang", async () => { + const tool = "\n#!/usr/bin/env python\nHow much wood would a woodchuck chuck if a woodchuck could chuck wood?" + const response = await g.parseContent(tool) + expect(response).toBeDefined() + expect(response).toHaveLength(1) + expect((response[0] as gptscript.Tool).instructions).toEqual("#!/usr/bin/env python\nHow much wood would a woodchuck chuck if a woodchuck could chuck wood?") + }, 30000) + + test("format tool", async () => { + const tool = { + id: "my-tool", + type: "tool" as ToolType, + tools: ["sys.write", "sys.read"], + instructions: "This is a test", + arguments: { + type: ArgumentSchemaType, + properties: { + text: { + type: PropertyType, + description: "The text to write" + } + } + } + } + + const response = await g.stringify([tool]) + expect(response).toBeDefined() + expect(response).toContain("Tools: sys.write, sys.read") + expect(response).toContain("This is a test") + expect(response).toContain("Parameter: text: The text to write") + }) + + test("format context tool", async () => { + const tool = { + id: "my-tool", + type: "context" as ToolType, + tools: ["sys.write", "sys.read"], + instructions: "This is a test", + arguments: { + type: ArgumentSchemaType, + properties: { + text: { + type: PropertyType, + description: "The text to write" + } + } + } + } + + const response = await g.stringify([tool]) + expect(response).toBeDefined() + expect(response).toContain("Tools: sys.write, sys.read") + expect(response).toContain("This is a test") + expect(response).toContain("Parameter: text: The text to write") + expect(response).toContain("Type: Context") + }) + + test("load simple file", async () => { + const response = await g.load(path.join(__dirname, "fixtures", "test.gpt")) + expect(response.program).toBeDefined() + expect(response.program.name).toBeTruthy() + expect(response.program.entryToolId).toBeTruthy() + expect(response.program.toolSet).toBeDefined() + }, 30000) + + test("load remote tool", async () => { + const response = await g.load("github.com/gptscript-ai/context/workspace") + expect(response.program).toBeDefined() + expect(response.program.name).toBeTruthy() + expect(response.program.entryToolId).toBeTruthy() + expect(response.program.toolSet).toBeDefined() + }, 30000) + + test("load content", async () => { + const content = fs.readFileSync(path.join(__dirname, "fixtures", "test.gpt"), {encoding: "utf8"}) + const response = await g.loadContent(content) + expect(response.program).toBeDefined() + // Name will not be defined in this case. + expect(response.program.name).toBeFalsy() + expect(response.program.entryToolId).toBeTruthy() + expect(response.program.toolSet).toBeDefined() + }, 30000) + + test("load tools", async () => { + const tools = [{ + tools: ["ask"], + instructions: "Only use the ask tool to ask who was the president of the united states in 1928?" + }, + { + name: "other", + instructions: "Who was the president of the united states in 1986?" + }, + { + name: "ask", + description: "This tool is used to ask a question", + arguments: { + type: "object", + question: "The question to ask" + }, + instructions: "${question}" + }, + ] as gptscript.ToolDef[] + const response = await g.loadTools(tools) + expect(response.program).toBeDefined() + // Name will not be defined in this case. + expect(response.program.name).toBeFalsy() + expect(response.program.entryToolId).toBeTruthy() + expect(response.program.toolSet).toBeDefined() + }, 30000) + + test("exec tool with chat", async () => { + let err = undefined + const t = { + chat: true, + instructions: "You are a chat bot. Don't finish the conversation until I say 'bye'.", + tools: ["sys.chat.finish"] + } + const opts = { + disableCache: true, + } + let run = await g.evaluate(t, opts) + + const inputs = [ + "List the three largest states in the United States by area.", + "What is the capital of the third one?", + "What timezone is the first one in?" + ] + + const expectedOutputs = [ + "California", + "Sacramento", + "Alaska Time Zone" + ] + + await run.text() + for (let i: number = 0; i < inputs.length; i++) { + run = run.nextChat(inputs[i]) + err = run.err + + if (err) { + break + } + + expect(await run.text()).toContain(expectedOutputs[i]) + expect(run.state).toEqual(gptscript.RunState.Continue) + } + + run = run.nextChat("bye") + await run.text() + + expect(run.state).toEqual(gptscript.RunState.Finished) + expect(err).toEqual("") + }, 60000) + + test("exec file with chat", async () => { + let err = undefined + const opts = { + disableCache: true + } + let run = await g.run(path.join(__dirname, "fixtures", "chat.gpt"), opts) + + const inputs = [ + "List the 3 largest of the Great Lakes by volume.", + "What is the volume of the second in the list in cubic miles?", + "What is the total area of the third in the list in square miles?" + ] + + const expectedOutputs = [ + "Lake Superior", + "Lake Michigan", + "Lake Huron" + ] + + await run.text() + for (let i: number = 0; i < inputs.length; i++) { + run = run.nextChat(inputs[i]) + err = run.err + + if (err) { + break + } + + expect(await run.text()).toContain(expectedOutputs[i]) + expect(run.state).toEqual(gptscript.RunState.Continue) + } + + run = run.nextChat("bye") + await run.text() + + expect(run.state).toEqual(gptscript.RunState.Finished) + expect(err).toEqual("") + }, 60000) + + test("nextChat on file providing chat state", async () => { + let run = await g.run(path.join(__dirname, "fixtures", "chat.gpt"), {disableCache: true}) + + run = run.nextChat("List the 3 largest of the Great Lakes by volume.") + expect(await run.text()).toContain("Lake Superior") + expect(run.err).toEqual("") + expect(run.state).toEqual(gptscript.RunState.Continue) + + run = await g.run(path.join(__dirname, "fixtures", "chat.gpt"), { + disableCache: true, + input: "What is the total area of the third one in square miles?", + chatState: run.currentChatState() + }) + + expect(await run.text()).toContain("Lake Huron") + expect(run.err).toEqual("") + expect(run.state).toEqual(gptscript.RunState.Continue) + }, 15000) + + test("nextChat on tool providing chat state", async () => { + const t = { + chat: true, + instructions: "You are a chat bot. Don't finish the conversation until I say 'bye'.", + tools: ["sys.chat.finish"] + } + let run = await g.evaluate(t, {disableCache: true}) + + run = run.nextChat("List the three largest states in the United States by area.") + expect(await run.text()).toContain("California") + expect(run.err).toEqual("") + expect(run.state).toEqual(gptscript.RunState.Continue) + + run = await g.evaluate(t, { + disableCache: true, + input: "What is the capital of the second one?", + chatState: run.currentChatState() + }) + + expect(await run.text()).toContain("Austin") + expect(run.err).toEqual("") + expect(run.state).toEqual(gptscript.RunState.Continue) + }, 15000) + + test("confirm", async () => { + const t = { + instructions: "List the files in the current working directory.", + tools: ["sys.exec"] + } + + const commands = [`ls`, `dir`] + let confirmCallCount = 0 + const run = await g.evaluate(t, {confirm: true}) + run.on(gptscript.RunEventType.CallConfirm, async (data: gptscript.CallFrame) => { + // On Windows, ls is not always a command. The LLM will try to run dir in this case. Allow both. + expect(data.input).toContain(commands[confirmCallCount]) + confirmCallCount++ + await g.confirm({id: data.id, accept: true}) + }) + + expect(await run.text()).toContain("README.md") + expect(run.err).toEqual("") + expect(confirmCallCount > 0).toBeTruthy() + }) + + test("do not confirm", async () => { + let confirmFound = false + const t = { + instructions: "List the files in the current directory as '.'. If that doesn't work print the word FAIL.", + tools: ["sys.exec"] + } + const run = await g.evaluate(t, {confirm: true}) + run.on(gptscript.RunEventType.CallConfirm, async (data: gptscript.CallFrame) => { + expect(data.input).toContain(`ls`) + confirmFound = true + await g.confirm({id: data.id, accept: false, message: "I will not allow it!"}) + }) + + expect(await run.text()).toContain("FAIL") + expect(run.err).toEqual("") + expect(confirmFound).toBeTruthy() + }) + + test("prompt", async () => { + let promptFound = false + const t = { + instructions: "Use the sys.prompt user to ask the user for 'first name' which is not sensitive. After you get their first name, say hello.", + tools: ["sys.prompt"] + } + const run = await g.evaluate(t, {prompt: true}) + run.on(gptscript.RunEventType.Prompt, async (data: gptscript.PromptFrame) => { + expect(data.message).toContain("first name") + expect(data.fields.length).toEqual(1) + expect(data.fields[0].name).toEqual("first name") + expect(data.sensitive).toBeFalsy() + + promptFound = true + await g.promptResponse({id: data.id, responses: {[data.fields[0].name]: "Clicky"}}) + }) + + expect(await run.text()).toContain("Clicky") + expect(run.err).toEqual("") + expect(promptFound).toBeTruthy() + }) + + test("prompt with metadata", async () => { + let promptFound = false + const run = await g.run("sys.prompt", { + prompt: true, + input: "{\"fields\":\"first name\",\"metadata\":{\"key\":\"value\"}}" + }) + run.on(gptscript.RunEventType.Prompt, async (data: gptscript.PromptFrame) => { + expect(data.fields.length).toEqual(1) + expect(data.fields[0].name).toEqual("first name") + expect(data.metadata).toEqual({key: "value"}) + expect(data.sensitive).toBeFalsy() + + promptFound = true + await g.promptResponse({id: data.id, responses: {[data.fields[0].name]: "Clicky"}}) + }) + + expect(await run.text()).toContain("Clicky") + expect(run.err).toEqual("") + expect(promptFound).toBeTruthy() + }) + + test("prompt without prompt allowed should fail", async () => { + let promptFound = false + const t = { + instructions: "Use the sys.prompt user to ask the user for 'first name' which is not sensitive. After you get their first name, say hello.", + tools: ["sys.prompt"] + } + const run = await g.evaluate(t) + run.on(gptscript.RunEventType.Prompt, async (data: gptscript.PromptFrame) => { + promptFound = true + }) + + try { + await run.text() + } catch (e: any) { + expect(e.toString()).toContain("prompt occurred") + } + expect(run.err).toContain("prompt occurred") + expect(promptFound).toBeFalsy() + }) + + test("retry failed run", async () => { + let shebang = `#!/bin/bash\nexit \${EXIT_CODE}` + if (process.platform == "win32") { + shebang = "#!/usr/bin/env powershell.exe\n$e = $env:EXIT_CODE;\nif ($e) { Exit 1; }" + } + const t = { + instructions: "say hello", + tools: ["my-context"] + } as gptscript.ToolDef + const contextTool = { + name: "my-context", + type: "context", + instructions: `${shebang}\nexit \${EXIT_CODE}` + } as gptscript.ToolDef + + let run = await g.evaluate([t, contextTool], {disableCache: true, env: ["EXIT_CODE=1"]}) + try { + await run.text() + } catch { + } + + expect(run.err).not.toEqual("") + + run.opts.env = [] + run = run.nextChat() + + await run.text() + + expect(run.err).toEqual("") + }) + + test("test get_env default", async () => { + const env = getEnv("TEST_ENV_MISSING", "foo") + expect(env).toEqual("foo") + }) + + test("test get_env", async () => { + process.env.TEST_ENV = "{\"_gz\":\"H4sIAEosrGYC/ytJLS5RKEvMKU0FACtB3ewKAAAA\"}" + const env = getEnv("TEST_ENV", "missing") + expect(env).toEqual("test value") + }) + + test("run file with metadata", async () => { + let err = undefined + let out = "" + let run = await g.run(path.join(__dirname, "fixtures", "parse-with-metadata.gpt")) + + try { + out = await run.text() + } catch (e) { + err = e + } + expect(err).toEqual(undefined) + expect(out).toEqual("200") + }, 20000) + + test("run parsed tool with metadata", async () => { + let err = undefined + let out = "" + const tools = await g.parse(path.join(__dirname, "fixtures", "parse-with-metadata.gpt")) + + for (const t of tools) { + if (t.type && t.type !== TextType) { + const run = await g.evaluate(t) + try { + out = await run.text() + } catch (e) { + err = e + } + } + } + + expect(err).toEqual(undefined) + expect(out).toEqual("200") + }, 20000) + + test("credential operations", async () => { + const name = "test-" + randomBytes(10).toString("hex") + const value = randomBytes(10).toString("hex") + + // Create + try { + await g.createCredential({ + name: name, + context: "default", + env: {"TEST": value}, + ephemeral: false, + expiresAt: new Date(Date.now() + 5000), // 5 seconds from now + type: CredentialType.Tool, + checkParam: "my-check-param", + }) + } catch (e) { + throw new Error("failed to create credential: " + e) + } + + // Wait 5 seconds + await new Promise(resolve => setTimeout(resolve, 5000)) + + // Reveal + try { + const result = await g.revealCredential(["default"], name) + expect(result.env["TEST"]).toEqual(value) + expect(result.expiresAt!.valueOf()).toBeLessThan(new Date().valueOf()) + expect(result.type).toEqual(CredentialType.Tool) + expect(result.checkParam).toEqual("my-check-param") + } catch (e) { + throw new Error("failed to reveal credential: " + e) + } + + // List + try { + const result = await g.listCredentials(["default"], false) + expect(result.length).toBeGreaterThan(0) + expect(result.map(c => c.name)).toContain(name) + } catch (e) { + throw new Error("failed to list credentials: " + e) + } + + // Delete + try { + await g.deleteCredential("default", name) + } catch (e) { + throw new Error("failed to delete credential: " + e) + } + + // Verify deletion + try { + const result = await g.listCredentials(["default"], false) + expect(result.map(c => c.name)).not.toContain(name) + } catch (e) { + throw new Error("failed to verify deletion: " + e) + } + }, 20000) + + test("dataset operations", async () => { + process.env.GPTSCRIPT_WORKSPACE_ID = await g.createWorkspace("directory") + + const client = new gptscript.GPTScript({ + APIKey: process.env.OPENAI_API_KEY, + Env: Object.entries(process.env).map(([k, v]) => `${k}=${v}`) + }) + + let datasetID: string + + // Create and add two elements + try { + datasetID = await client.addDatasetElements([ + { + name: "element1", + description: "", + contents: "this is element 1 contents" + }, + { + name: "element2", + description: "a description", + binaryContents: Buffer.from("this is element 2 contents") + } + ], {name: "test-dataset", description: "a test dataset"}) + } catch (e) { + throw new Error("failed to create dataset: " + e) + } + + // Add another element + try { + await client.addDatasetElements([ + { + name: "element3", + description: "a description", + contents: "this is element 3 contents" + } + ], {datasetID: datasetID}) + } catch (e) { + throw new Error("failed to add elements: " + e) + } + + // Get elements + try { + const e1 = await client.getDatasetElement(datasetID, "element1") + expect(e1.name).toEqual("element1") + expect(e1.description).toBeUndefined() + expect(e1.contents).toEqual("this is element 1 contents") + + const e2 = await client.getDatasetElement(datasetID, "element2") + expect(e2.name).toEqual("element2") + expect(e2.description).toEqual("a description") + expect(e2.binaryContents).toEqual(Buffer.from("this is element 2 contents")) + + const e3 = await client.getDatasetElement(datasetID, "element3") + expect(e3.name).toEqual("element3") + expect(e3.description).toEqual("a description") + expect(e3.contents).toEqual("this is element 3 contents") + } catch (e) { + throw new Error("failed to get elements: " + e) + } + + // List the elements in the dataset + try { + const elements = await client.listDatasetElements(datasetID) + expect(elements.length).toEqual(3) + expect(elements.map(e => e.name)).toContain("element1") + expect(elements.map(e => e.name)).toContain("element2") + expect(elements.map(e => e.name)).toContain("element3") + } catch (e) { + throw new Error("failed to list elements: " + e) + } + + // List datasets + try { + const datasets = await client.listDatasets() + expect(datasets.length).toBeGreaterThan(0) + expect(datasets[0].id).toEqual(datasetID) + expect(datasets[0].name).toEqual("test-dataset") + expect(datasets[0].description).toEqual("a test dataset") + } catch (e) { + throw new Error("failed to list datasets: " + e) + } + + client.close() + }, 60000) + + test("create and delete workspace", async () => { + const workspaceID = await g.createWorkspace("directory") + expect(workspaceID).toBeDefined() + await g.deleteWorkspace(workspaceID) + }, 60000) + + test("write, read, and delete file", async () => { + const workspaceID = await g.createWorkspace("directory") + expect(workspaceID).toBeDefined() + + await g.writeFileInWorkspace("test.txt", Buffer.from("test"), workspaceID) + const content = await g.readFileInWorkspace("test.txt", workspaceID) + expect(content.toString()).toEqual("test") + + const fileInfo = await g.statFileInWorkspace("test.txt", workspaceID) + expect(fileInfo.size).toEqual(4) + expect(fileInfo.name).toEqual("test.txt") + expect(fileInfo.workspaceID).toEqual(workspaceID) + expect(fileInfo.modTime).toBeDefined() + + await g.deleteFileInWorkspace("test.txt", workspaceID) + await g.deleteWorkspace(workspaceID) + }, 60000) + + test("test complex ls", async () => { + const workspaceID = await g.createWorkspace("directory") + + // Write files in the workspace + await g.writeFileInWorkspace("test/test1.txt", Buffer.from("hello1"), workspaceID) + await g.writeFileInWorkspace("test1/test2.txt", Buffer.from("hello2"), workspaceID) + await g.writeFileInWorkspace("test1/test3.txt", Buffer.from("hello3"), workspaceID) + await g.writeFileInWorkspace(".hidden.txt", Buffer.from("hidden"), workspaceID) + + let content = await g.listFilesInWorkspace(undefined, workspaceID) + expect(content.length).toEqual(4) + expect(content).toContain("test1/test2.txt") + expect(content).toContain("test1/test3.txt") + expect(content).toContain("test/test1.txt") + expect(content).toContain(".hidden.txt") + + content = await g.listFilesInWorkspace("test1", workspaceID) + expect(content.length).toEqual(2) + expect(content).toContain("test1/test2.txt") + expect(content).toContain("test1/test3.txt") + + await g.removeAll("test1", workspaceID) + + content = await g.listFilesInWorkspace("", workspaceID) + expect(content.length).toEqual(2) + expect(content).toContain("test/test1.txt") + expect(content).toContain(".hidden.txt") + + await g.deleteWorkspace(workspaceID) + }, 60000) + + test("create and delete workspace in s3", async () => { + if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { + console.log("AWS credentials not set, skipping test") + return + } + + const workspaceID = await g.createWorkspace("s3") + expect(workspaceID).toBeDefined() + await g.deleteWorkspace(workspaceID) + }, 60000) + + test("write, read, and delete file in s3", async () => { + if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { + console.log("AWS credentials not set, skipping test") + return + } + + const workspaceID = await g.createWorkspace("s3") + expect(workspaceID).toBeDefined() + + await g.writeFileInWorkspace("test.txt", Buffer.from("test"), workspaceID) + const content = await g.readFileInWorkspace("test.txt", workspaceID) + expect(content.toString()).toEqual("test") + + const fileInfo = await g.statFileInWorkspace("test.txt", workspaceID) + expect(fileInfo.size).toEqual(4) + expect(fileInfo.name).toEqual("test.txt") + expect(fileInfo.workspaceID).toEqual(workspaceID) + expect(fileInfo.modTime).toBeDefined() + + await g.deleteFileInWorkspace("test.txt", workspaceID) + await g.deleteWorkspace(workspaceID) + }, 60000) + + test("test complex ls in s3", async () => { + if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { + console.log("AWS credentials not set, skipping test") + return + } + + const workspaceID = await g.createWorkspace("s3") + + // Write files in the workspace + await g.writeFileInWorkspace("test/test1.txt", Buffer.from("hello1"), workspaceID) + await g.writeFileInWorkspace("test1/test2.txt", Buffer.from("hello2"), workspaceID) + await g.writeFileInWorkspace("test1/test3.txt", Buffer.from("hello3"), workspaceID) + await g.writeFileInWorkspace(".hidden.txt", Buffer.from("hidden"), workspaceID) + + let content = await g.listFilesInWorkspace(undefined, workspaceID) + expect(content.length).toEqual(4) + expect(content).toContain("test1/test2.txt") + expect(content).toContain("test1/test3.txt") + expect(content).toContain("test/test1.txt") + expect(content).toContain(".hidden.txt") + + content = await g.listFilesInWorkspace("test1", workspaceID) + expect(content.length).toEqual(2) + expect(content).toContain("test1/test2.txt") + expect(content).toContain("test1/test3.txt") + + await g.removeAll("test1", workspaceID) + + content = await g.listFilesInWorkspace("", workspaceID) + expect(content.length).toEqual(2) + expect(content).toContain("test/test1.txt") + expect(content).toContain(".hidden.txt") + + await g.deleteWorkspace(workspaceID) + }, 60000) })