diff --git a/.github/workflows/dispatch.yaml b/.github/workflows/dispatch.yaml new file mode 100644 index 0000000..711fa82 --- /dev/null +++ b/.github/workflows/dispatch.yaml @@ -0,0 +1,44 @@ +name: Update GPTScript Version +on: + repository_dispatch: + types: release + +jobs: + update-gptscript-dep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Update GPTScript Version + run: | + TAG=${{ github.event.client_payload.tag }} + echo "${TAG#v}" >> $VERSION + sed -i 's/version = "[0-9.]*"/version = "'${VERSION}'"/' pyproject.toml + sed -i 's/version: "v[0-9.]*"/version: ""/' gptscript/install.py + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install deps + run: | + pip install -r requirements.txt + - uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: Automated GPTScript Version Update + file_pattern: 'pyproject.toml gptscript/install.py' + tag-release: + needs: update-gptscript-dep + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + custom_tag: ${{ github.event.client_payload.tag }} + tag_prefix: "" + - name: Create a GitHub release + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + name: Release ${{ steps.tag_version.outputs.new_tag }} + body: ${{ steps.tag_version.outputs.changelog }} diff --git a/README.md b/README.md index d398d99..bc6768e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ ## Introduction -The GPTScript Python module is a library that provides a simple interface to create and run gptscripts within Python applications, and Jupyter notebooks. It allows you to define tools, execute them, and process the responses. +The GPTScript Python module is a library that provides a simple interface to create and run gptscripts within Python +applications, and Jupyter notebooks. It allows you to define tools, execute them, and process the responses. ## Installation @@ -16,7 +17,8 @@ On MacOS, Windows X6 ### SDIST and none-any wheel installations -When installing from the sdist or the none-any wheel, the binary is not packaged by default. You must run the install_gptscript command to install the binary. +When installing from the sdist or the none-any wheel, the binary is not packaged by default. You must run the +install_gptscript command to install the binary. ```bash install_gptscript @@ -28,6 +30,7 @@ Or you can install the gptscript cli from your code by running: ```python from gptscript.install import install + install() ``` @@ -39,252 +42,280 @@ If you already have the gptscript cli installed, you can use it by setting the e export GPTSCRIPT_BIN="/path/to/gptscript" ``` -## Using the Module +## GPTScript -The module requires the OPENAI_API_KEY environment variable to be set with your OPENAI key. You can set it in your shell or in your code. +The GPTScript instance allows the caller to run gptscript files, tools, and other operations (see below). Note that the +intention is that a single GPTScript instance is all you need for the life of your application, you should +call `close()` on the instance when you are done. -```bash -export OPENAI_AI_KEY="your-key" -``` +## Global Options + +When creating a `GTPScript` instance, you can pass the following global options. These options are also available as +run `Options`. Anything specified as a run option will take precedence over the global option. + +- `APIKey`: Specify an OpenAI API key for authenticating requests. Defaults to `OPENAI_API_KEY` environment variable +- `BaseURL`: A base URL for an OpenAI compatible API (the default is `https://api.openai.com/v1`) +- `DefaultModel`: The default model to use for OpenAI requests +- `Env`: Supply the environment variables. Supplying anything here means that nothing from the environment is used. The + default is `os.environ()`. Supplying `Env` at the run/evaluate level will be treated as "additional." + +## Run Options + +These are optional options that can be passed to the `run` and `evaluate` 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. + +- `disableCache`: Enable or disable caching. Default (False). +- `subTool`: Use tool of this name, not the first tool +- `input`: Input arguments for the tool run +- `workspace`: Directory to use for the workspace, if specified it will not be deleted on exit +- `chatState`: The chat state to continue, or null to start a new chat and return the state +- `confirm`: Prompt before running potentially dangerous commands +- `prompt`: Allow prompting of the user ## Tools -The `Tool` class represents a gptscript tool. The fields align with what you would be able to define in a normal gptscript .gpt file. +The `Tool` class represents a gptscript tool. The fields align with what you would be able to define in a normal +gptscript .gpt file. ### Fields - `name`: The name of the tool. - `description`: A description of the tool. - `tools`: Additional tools associated with the main tool. -- `max_tokens`: The maximum number of tokens to generate. +- `maxTokens`: The maximum number of tokens to generate. - `model`: The GPT model to use. - `cache`: Whether to use caching for responses. - `temperature`: The temperature parameter for response generation. -- `args`: Additional arguments for the tool. -- `internal_prompt`: Boolean defaults to false. +- `arguments`: Additional arguments for the tool. +- `internalPrompt`: Optional boolean defaults to None. - `instructions`: Instructions or additional information about the tool. -- `json_response`: Whether the response should be in JSON format.(If you set this to True, you must say 'json' in the instructions as well.) +- `jsonResponse`: Whether the response should be in JSON format.(If you set this to True, you must say 'json' in the + instructions as well.) ## Primary Functions -Aside from the list methods there are `exec` and `exec_file` methods that allow you to execute a tool and get the responses. Those functions also provide a streaming version of execution if you want to process the output streams in your code as the tool is running. +Aside from the list methods there are `exec` and `exec_file` methods that allow you to execute a tool and get the +responses. Those functions also provide a streaming version of execution if you want to process the output streams in +your code as the tool is running. -### Opts +### `list_tools()` -You can pass the following options to the exec and exec_file functions. See `gptscript --help` for further information. +This function lists the available tools. -opts= { - "cache": True(default)|False, - "cache-dir": "/path/to/dir", - "quiet": True|False(default), - "chdir": "/path/to/dir", - "subTool": "tool-name", -} +```python +from gptscript.gptscript import GPTScript -Cache can be set to true or false to enable or disable caching globally or it can be set at the individual tool level. The cache-dir can be set to a directory to use for caching. If not set, the default cache directory will be used. + +async def list_tools(): + gptscript = GPTScript() + tools = await gptscript.list_tools() + print(tools) + gptscript.close() +``` ### `list_models()` This function lists the available GPT models. ```python -from gptscript.command import list_models +from gptscript.gptscript import GPTScript + -models = list_models() -print(models) +async def list_models(): + gptscript = GPTScript() + tools = await gptscript.list_models() + print(tools) + gptscript.close() ``` -### `list_tools()` +### `parse()` -This function lists the available tools. +Parse a file into a Tool data structure. ```python -from gptscript.command import list_tools +from gptscript.gptscript import GPTScript -tools = list_tools() -print(tools) + +async def parse_example(): + gptscript = GPTScript() + tools = await gptscript.parse("/path/to/file") + print(tools) + gptscript.close() ``` -### `exec(tool, opts)` +### `parse_tool()` -This function executes a tool and returns the response. +Parse the contents that represents a GPTScript file into a Tool data structure. ```python -from gptscript.command import exec -from gptscript.tool import Tool +from gptscript.gptscript import GPTScript -tool = Tool( - json_response=True, - instructions=""" -Create three short graphic artist descriptions and their muses. -These should be descriptive and explain their point of view. -Also come up with a made up name, they each should be from different -backgrounds and approach art differently. -the response should be in JSON and match the format: -{ - artists: [{ - name: "name" - description: "description" - }] -} -""", - ) - - -response = exec(tool) -print(response) + +async def parse_tool_example(): + gptscript = GPTScript() + tools = await gptscript.parse_tool("Instructions: Say hello!") + print(tools) + gptscript.close() ``` -### `exec_file(tool_path, input="", opts)` +### `fmt()` -This function executes a tool from a file and returns the response. The input values are passed to the tool as args. +Parse convert a tool data structure into a GPTScript file. ```python -from gptscript.command import exec_file +from gptscript.gptscript import GPTScript + + +async def fmt_example(): + gptscript = GPTScript() + tools = await gptscript.parse_tool("Instructions: Say hello!") + print(tools) -response = exec_file("./example.gpt") -print(response) + contents = gptscript.fmt(tools) + print(contents) # This would print "Instructions: Say hello!" + gptscript.close() ``` -### `stream_exec(tool, opts)` +### `evaluate()` -This function streams the execution of a tool and returns the output, error, and process wait function. The streams must be read from. +Executes a tool with optional arguments. ```python -from gptscript.command import stream_exec -from gptscript.tool import Tool +from gptscript.gptscript import GPTScript +from gptscript.tool import ToolDef -tool = Tool( - json_response=True, - instructions=""" -Create three short graphic artist descriptions and their muses. -These should be descriptive and explain their point of view. -Also come up with a made up name, they each should be from different -backgrounds and approach art differently. -the response should be in JSON and match the format: -{ - artists: [{ - name: "name" - description: "description" - }] -} -""", - ) - -def print_output(out, err): - # Error stream has the debug info that is useful to see - for line in err: - print(line) - - for line in out: - print(line) - -out, err, wait = stream_exec(tool) -print_output(out, err) -wait() + +async def evaluate_example(): + tool = ToolDef(instructions="Who was the president of the United States in 1928?") + gptscript = GPTScript() + + run = gptscript.evaluate(tool) + output = await run.text() + + print(output) + + gptscript.close() ``` -### `stream_exec_with_events(tool, opts)` +### `run()` -This function streams the execution of a tool and returns the output, error, event stream, and process wait function. The streams must be read from. +Executes a GPT script file with optional input and arguments. The script is relative to the callers source directory. ```python -from gptscript.command import stream_exec_with_events -from gptscript.tool import Tool +from gptscript.gptscript import GPTScript -tool = Tool( - json_response=True, - instructions=""" -Create three short graphic artist descriptions and their muses. -These should be descriptive and explain their point of view. -Also come up with a made up name, they each should be from different -backgrounds and approach art differently. -the response should be in JSON and match the format: -{ - artists: [{ - name: "name" - description: "description" - }] -} -""", - ) - -def print_output(out, err, events): - for event in events: - print(event) - - # Error stream has the debug info that is useful to see - for line in err: - print(line) - - for line in out: - print(line) - -out, err, events, wait = stream_exec_with_events(tool) -print_output(out, err, events) -wait() + +async def evaluate_example(): + gptscript = GPTScript() + + run = gptscript.run("/path/to/file") + output = await run.text() + + print(output) + + gptscript.close() +``` + +### Streaming events + +GPTScript provides events for the various steps it takes. You can get those events and process them +with `event_handlers`. The `evaluate` method is used here, but the same functionality exists for the `run` method. + +```python +from gptscript.gptscript import GPTScript +from gptscript.frame import RunFrame, CallFrame, PromptFrame +from gptscript.run import Run + + +async def process_event(run: Run, event: RunFrame | CallFrame | PromptFrame): + print(event.__dict__) + + +async def evaluate_example(): + gptscript = GPTScript() + + run = gptscript.run("/path/to/file", event_handlers=[process_event]) + output = await run.text() + + print(output) + + gptscript.close() ``` -### `stream_exec_file(tool_path, input="",opts)` +### Confirm -This function streams the execution of a tool from a file and returns the output, error, and process wait function. The input values are passed to the tool as args. +Using the `confirm: true` option allows a user to inspect potentially dangerous commands before they are run. The caller +has the ability to allow or disallow their running. In order to do this, a caller should look for the `CallConfirm` +event. ```python -from gptscript.command import stream_exec_file +from gptscript.gptscript import GPTScript +from gptscript.frame import RunFrame, CallFrame, PromptFrame +from gptscript.run import Run, RunEventType +from gptscript.confirm import AuthResponse + +gptscript = GPTScript() + + +async def confirm(run: Run, event: RunFrame | CallFrame | PromptFrame): + if event.type == RunEventType.callConfirm: + # AuthResponse also has a "message" field to specify why the confirm was denied. + await gptscript.confirm(AuthResponse(accept=True)) -def print_output(out, err): - # Error stream has the debug info that is useful to see - for line in err: - print(line) - for line in out: - print(line) +async def evaluate_example(): + run = gptscript.run("/path/to/file", event_handlers=[confirm]) + output = await run.text() -out, err, wait = stream_exec_file("./init.gpt") -print_output(out, err) -wait() + print(output) + + gptscript.close() ``` -### `stream_exec_file_with_events(tool_path, input="",opts)` +### Prompt -This function streams the execution of a tool from a file and returns the output, error, event stream, and process wait function. The input values are passed to the tool as args. +Using the `prompt: true` option allows a script to prompt a user for input. In order to do this, a caller should look +for the `Prompt` event. Note that if a `Prompt` event occurs when it has not explicitly been allowed, then the run will +error. ```python -from gptscript.command import stream_exec_file_with_events +from gptscript.gptscript import GPTScript +from gptscript.frame import RunFrame, CallFrame, PromptFrame +from gptscript.run import Run, RunEventType +from gptscript.prompt import PromptResponse + +gptscript = GPTScript() + -def print_output(out, err, events): - for event in events: - print(event) +async def prompt(run: Run, event: RunFrame | CallFrame | PromptFrame): + if isinstance(event, PromptFrame): + # The responses field here is a dictionary of prompt fields to values. + await gptscript.prompt(PromptResponse(id=event.id, responses={event.fields[0]: "Some value"})) - # Error stream has the debug info that is useful to see - for line in err: - print(line) - for line in out: - print(line) +async def evaluate_example(): + run = gptscript.run("/path/to/file", event_handlers=[prompt]) + output = await run.text() -out, err, events, wait = stream_exec_file_with_events("./init.gpt") -print_output(out, err, events) -wait() + print(output) + + gptscript.close() ``` ## Example Usage ```python -from gptscript.command import exec -from gptscript.tool import FreeForm, Tool - -# Define a simple tool -simple_tool = FreeForm( - content=""" -What is the capital of the United States? -""" -) +from gptscript.gptscript import GPTScript +from gptscript.tool import ToolDef -# Define a complex tool -complex_tool = Tool( +# Create the GPTScript object +gptscript = GPTScript() + +# Define a tool +complex_tool = ToolDef( tools=["sys.write"], - json_response=True, + jsonResponse=True, cache=False, instructions=""" Create three short graphic artist descriptions and their muses. @@ -302,27 +333,26 @@ complex_tool = Tool( ) # Execute the complex tool -response, err = exec(complex_tool) -print(err) -print(response) - -# Execute the simple tool -resp, err = exec(simple_tool) -print(err) -print(resp) +run = gptscript.evaluate(complex_tool) +print(await run.text()) + +gptscript.close() ``` ### Example 2 multiple tools -In this example, multiple tool are provided to the exec function. The first tool is the only one that can exclude the name field. These will be joined and passed into the gptscript as a single gpt script. +In this example, multiple tool are provided to the exec function. The first tool is the only one that can exclude the +name field. These will be joined and passed into the gptscript as a single gptscript. ```python -from gptscript.command import exec -from gptscript.tool import Tool +from gptscript.gptscript import GPTScript +from gptscript.tool import ToolDef + +gptscript = GPTScript() tools = [ - Tool(tools=["echo"], instructions="echo hello times"), - Tool( + ToolDef(tools=["echo"], instructions="echo hello times"), + ToolDef( name="echo", tools=["sys.exec"], description="Echo's the input", @@ -334,7 +364,9 @@ tools = [ ), ] -resp, err = exec(tools) -print(err) -print(resp) +run = gptscript.evaluate(tools) + +print(await run.text()) + +gptscript.close() ``` diff --git a/gptscript/command.py b/gptscript/command.py deleted file mode 100644 index 63c6e4f..0000000 --- a/gptscript/command.py +++ /dev/null @@ -1,163 +0,0 @@ -import os -import sys - -from gptscript.exec_utils import exec_cmd, stream_exec_cmd, stream_exec_cmd_with_events -from gptscript.tool import FreeForm, Tool - -opt_to_arg = { - "cache": "--disable-cache=", - "cacheDir": "--cache-dir=", - "quiet": "--quiet=", - "chdir": "--chdir=", - "subTool": "--sub-tool=", -} - - -def _get_command(): - if os.getenv("GPTSCRIPT_BIN") is not None: - return os.getenv("GPTSCRIPT_BIN") - - python_bin_dir = os.path.dirname(sys.executable) - return os.path.join(python_bin_dir, "gptscript") - - -def version(): - cmd = _get_command() - out, _ = exec_cmd(cmd, ["--version"]) - return out - - -def list_tools(): - cmd = _get_command() - out, _ = exec_cmd(cmd, ["--list-tools"]) - return out - - -def list_models(): - cmd = _get_command() - try: - models, _ = exec_cmd(cmd, ["--list-models"]) - return models.strip().split("\n") - except Exception as e: - raise e - - -def exec(tool, opts={}): - cmd = _get_command() - args = to_args(opts) - args.append("-") - try: - tool_str = process_tools(tool) - out, err = exec_cmd(cmd, args, input=tool_str) - return out, err - except Exception as e: - raise e - - -def stream_exec(tool, opts={}): - cmd = _get_command() - args = to_args(opts) - args.append("-") - try: - tool_str = process_tools(tool) - process = stream_exec_cmd(cmd, args, input=tool_str) - return process.stdout, process.stderr, process.wait - except Exception as e: - raise e - - -def stream_exec_with_events(tool, opts={}): - cmd = _get_command() - args = to_args(opts) - args.append("-") - try: - tool_str = process_tools(tool) - process, events = stream_exec_cmd_with_events(cmd, args, input=tool_str) - return process.stdout, process.stderr, events, process.wait - except Exception as e: - raise e - - -def exec_file(tool_path, input="", opts={}): - cmd = _get_command() - args = to_args(opts) - - args.append(tool_path) - - if input != "": - args.append(input) - try: - out, err = exec_cmd(cmd, args) - return out, err - except Exception as e: - raise e - - -def stream_exec_file(tool_path, input="", opts={}): - cmd = _get_command() - args = to_args(opts) - - args.append(tool_path) - - if input != "": - args.append(input) - try: - process = stream_exec_cmd(cmd, args) - - return process.stdout, process.stderr, process.wait - except Exception as e: - raise e - - -def stream_exec_file_with_events(tool_path, input="", opts={}): - cmd = _get_command() - args = to_args(opts) - - args.append(tool_path) - - if input != "": - args.append(input) - try: - process, events = stream_exec_cmd_with_events(cmd, args) - - return process.stdout, process.stderr, events, process.wait - except Exception as e: - raise e - - -def to_args(opts): - args = [] - for opt, val in opts.items(): - opt_arg = opt_to_arg.get(opt, None) - if opt_arg is not None: - if opt == "cache": - args.append(opt_arg + str(not val)) - else: - args.append(opt_arg + val) - return args - - -def process_tools(tools): - if isinstance(tools, Tool): - return str(tools) - - if isinstance(tools, list): - if len(tools) > 0: - if isinstance(tools[0], Tool): - return tool_concat(tools) - else: - raise Exception("Invalid tool type must be [Tool] or FreeForm") - elif isinstance(tools, FreeForm): - return str(tools) - else: - raise Exception("Invalid tool type must be [Tool] or FreeForm") - - -def tool_concat(tools=[]): - resp = "" - if len(tools) == 1: - return str(tools[0]) - if tools: - resp = "\n---\n".join([str(tool) for tool in tools]) - - return resp diff --git a/gptscript/confirm.py b/gptscript/confirm.py new file mode 100644 index 0000000..53abcbc --- /dev/null +++ b/gptscript/confirm.py @@ -0,0 +1,9 @@ +class AuthResponse: + def __init__(self, + id: str = "", + accept: bool = "", + message: str = "", + ): + self.id = id + self.accept = accept + self.message = message diff --git a/gptscript/exec_utils.py b/gptscript/exec_utils.py index 5b7596b..22fd463 100644 --- a/gptscript/exec_utils.py +++ b/gptscript/exec_utils.py @@ -1,5 +1,5 @@ -import subprocess import os +import subprocess import sys if sys.platform == "win32": @@ -19,10 +19,7 @@ def exec_cmd(cmd, args=[], input=None): encoding="utf-8", ) - if input is None: - return process.communicate() - else: - return process.communicate(input=input) + return process.communicate(input=input) except Exception as e: raise e @@ -48,11 +45,11 @@ def stream_exec_cmd_with_events(cmd, args=[], input=None): # Duplicate the handle to make it inheritable proc_handle = win32api.GetCurrentProcess() dup_handle = win32api.DuplicateHandle( - proc_handle, # Source process handle - w_handle, # Source handle - proc_handle, # Target process handle - 0, # Desired access (0 defaults to same as source) - 1, # Inherit handle + proc_handle, # Source process handle + w_handle, # Source handle + proc_handle, # Target process handle + 0, # Desired access (0 defaults to same as source) + 1, # Inherit handle win32con.DUPLICATE_SAME_ACCESS # Options ) args = ["--events-stream-to=fd://" + str(int(dup_handle))] + args diff --git a/gptscript/frame.py b/gptscript/frame.py new file mode 100644 index 0000000..c136611 --- /dev/null +++ b/gptscript/frame.py @@ -0,0 +1,184 @@ +from enum import Enum +from typing import Any + +from gptscript.tool import Tool, ToolReference + + +class RunEventType(Enum): + event = "event", + runStart = "runStart", + runFinish = "runFinish", + callStart = "callStart", + callChat = "callChat", + callSubCalls = "callSubCalls", + callProgress = "callProgress", + callConfirm = "callConfirm", + callContinue = "callContinue", + callFinish = "callFinish", + prompt = "prompt" + + +class RunState(Enum): + Creating = "creating", + Running = "running", + Continue = "continue", + Finished = "finished", + Error = "error" + + def is_terminal(self): + return self.value == RunState.Error or self.value == RunState.Finished + + +class Program: + def __init__(self, + name: str = "", + entryToolId: str = "", + toolSet: dict[str, Tool] = None, + ): + self.name = name + self.entryToolId = entryToolId + self.toolSet = toolSet + for tool in toolSet: + if isinstance(self.toolSet[tool], dict): + self.toolSet[tool] = Tool(**self.toolSet[tool]) + + +class RunFrame: + def __init__(self, + id: str = "", + type: RunEventType = RunEventType.runStart, + program: Program = None, + input: str = "", + output: str = "", + error: str = "", + start: str = "", + end: str = "", + state: RunState = RunState.Creating, + chatState: str = "", + ): + self.id = id + self.type = type + if isinstance(self.type, str): + self.type = RunEventType[self.type] + self.program = program + if isinstance(self.program, dict): + self.program = Program(**self.program) + self.input = input + self.output = output + self.error = error + self.start = start + self.end = end + self._state = state + self.chatState = chatState + + +class Call: + def __init__(self, + toolID: str = "", + input: str = "", + ): + self.toolID = toolID + self.input = input + + +class Output: + def __init__(self, + content: str = "", + subCalls: dict[str, Call] = None, + ): + self.content = content + self.subCalls = subCalls + + +class InputContext: + def __init__(self, + toolID: str = "", + content: str = "", + ): + self.toolID = toolID + self.content = content + + +class Usage: + def __init__(self, + promptTokens: int = 0, + completionTokens: int = 0, + totalTokens: int = 0, + ): + self.promptTokens = promptTokens + self.completionTokens = completionTokens + self.totalTokens = totalTokens + + +class CallFrame: + def __init__(self, + id: str = "", + tool: Tool = None, + agentGroup: list[ToolReference] = None, + displayText: str = "", + inputContext: list[InputContext] = None, + toolCategory: str = "", + toolName: str = "", + parentID: str = "", + type: RunEventType = RunEventType.event, + start: str = "", + end: str = "", + input: str = "", + output: list[Output] = None, + error: str = "", + usage: Usage = None, + llmRequest: Any = None, + llmResponse: Any = None, + ): + self.id = id + self.tool = tool + self.agentGroup = agentGroup + if self.agentGroup is not None: + for i in range(len(self.agentGroup)): + if isinstance(self.agentGroup[i], dict): + self.agentGroup[i] = ToolReference(**self.agentGroup[i]) + self.displayText = displayText + self.inputContext = inputContext + if self.inputContext is not None: + for i in range(len(self.inputContext)): + if isinstance(self.inputContext[i], dict): + self.inputContext[i] = InputContext(**self.inputContext[i]) + self.toolCategory = toolCategory + self.toolName = toolName + self.parentID = parentID + self.type = type + if isinstance(self.type, str): + self.type = RunEventType[self.type] + self.start = start + self.end = end + self.input = input + self.output = output + if self.output is not None: + for i in range(len(self.output)): + if isinstance(self.output[i], dict): + self.output[i] = Output(**self.output[i]) + self.error = error + self.usage = usage + if isinstance(self.usage, dict): + self.usage = Usage(**self.usage) + self.llmRequest = llmRequest + self.llmResponse = llmResponse + + +class PromptFrame: + def __init__(self, + id: str = "", + type: RunEventType = RunEventType.prompt, + time: str = "", + message: str = "", + fields: list[str] = None, + sensitive: bool = False, + ): + self.id = id + self.time = time + self.message = message + self.fields = fields + self.sensitive = sensitive + self.type = type + if isinstance(self.type, str): + self.type = RunEventType[self.type] diff --git a/gptscript/gptscript.py b/gptscript/gptscript.py new file mode 100644 index 0000000..6e09c45 --- /dev/null +++ b/gptscript/gptscript.py @@ -0,0 +1,147 @@ +import json +import os +from socket import socket +from subprocess import Popen, PIPE +from sys import executable +from time import sleep +from typing import Any, Callable, Awaitable + +import requests + +from gptscript.confirm import AuthResponse +from gptscript.frame import RunFrame, CallFrame, PromptFrame +from gptscript.opts import GlobalOptions +from gptscript.prompt import PromptResponse +from gptscript.run import Run, RunBasicCommand, Options +from gptscript.text import Text +from gptscript.tool import ToolDef, Tool + + +class GPTScript: + __gptscript_count = 0 + __server_url = "" + __process: Popen = None + __server_ready: bool = False + + def __init__(self, opts: GlobalOptions = None): + if opts is None: + opts = GlobalOptions() + GPTScript.__gptscript_count += 1 + + if GPTScript.__server_url == "": + GPTScript.__server_url = os.environ.get("GPTSCRIPT_URL", "") + + if GPTScript.__gptscript_count == 1 and os.environ.get("GPTSCRIPT_DISABLE_SERVER", "") != "true": + if GPTScript.__server_url == "": + with socket() as s: + s.bind(("", 0)) + GPTScript.__server_url = f"127.0.0.1:{s.getsockname()[1]}" + + opts.toEnv() + + GPTScript.__process = Popen( + [_get_command(), "--listen-address", GPTScript.__server_url, "sdkserver"], + stdin=PIPE, + stdout=PIPE, + stderr=PIPE, + env=opts.Env, + text=True, + encoding="utf-8", + ) + + self._server_url = f"http://{GPTScript.__server_url}" + self._wait_for_gptscript() + + def _wait_for_gptscript(self): + if not GPTScript.__server_ready: + for _ in range(0, 20): + try: + resp = requests.get(self._server_url + "/healthz") + if resp.status_code == 200: + GPTScript.__server_ready = True + return + except requests.exceptions.ConnectionError: + pass + + sleep(1) + + raise Exception("Failed to start gptscript") + + def close(self): + GPTScript.__gptscript_count -= 1 + if GPTScript.__gptscript_count == 0: + GPTScript.__process.stdin.close() + GPTScript.__process.wait() + GPTScript.__server_ready = False + GPTScript.__process = None + self._server_url = "" + + def evaluate( + self, + tool: ToolDef | list[ToolDef], + opts: Options = None, + event_handlers: list[Callable[[Run, CallFrame | RunFrame | PromptFrame], Awaitable[None]]] = None + ) -> Run: + return Run("evaluate", tool, opts, self._server_url, event_handlers=event_handlers).next_chat( + "" if opts is None else opts.input + ) + + def run( + self, tool_path: str, + opts: Options = None, + event_handlers: list[Callable[[Run, CallFrame | RunFrame | PromptFrame], Awaitable[None]]] = None + ) -> Run: + return Run("run", tool_path, opts, self._server_url, event_handlers=event_handlers).next_chat( + "" if opts is None else opts.input + ) + + async def parse(self, file_path: str) -> list[Text | Tool]: + out = await self._run_basic_command("parse", {"file": file_path}) + parsed_nodes = json.loads(out) + return [Text(**node["textNode"]) if "textNode" in node else Tool(**node.get("toolNode", {}).get("tool", {})) for + node in parsed_nodes.get("nodes", [])] + + async def parse_tool(self, tool_def: str) -> list[Text | Tool]: + out = await self._run_basic_command("parse", {"content": tool_def}) + parsed_nodes = json.loads(out) + return [Text(**node["textNode"]) if "textNode" in node else Tool(**node.get("toolNode", {}).get("tool", {})) for + node in parsed_nodes.get("nodes", [])] + + async def fmt(self, nodes: list[Text | Tool]) -> str: + request_nodes = [] + for node in nodes: + request_nodes.append(node.to_json()) + return await self._run_basic_command("fmt", {"nodes": request_nodes}) + + async def confirm(self, resp: AuthResponse): + await self._run_basic_command("confirm/" + resp.id, {**vars(resp)}) + + async def prompt(self, resp: PromptResponse): + await self._run_basic_command("prompt-response/" + resp.id, resp.responses) + + async def _run_basic_command(self, sub_command: str, request_body: Any = None): + run = RunBasicCommand(sub_command, request_body, self._server_url) + + run.next_chat() + + out = await run.text() + if run.err() != "": + return f"an error occurred: {out}" + + return out + + async def version(self) -> str: + return await self._run_basic_command("version") + + async def list_tools(self) -> str: + return await self._run_basic_command("list-tools") + + async def list_models(self) -> list[str]: + return (await self._run_basic_command("list-models")).split("\n") + + +def _get_command(): + if os.getenv("GPTSCRIPT_BIN") is not None: + return os.getenv("GPTSCRIPT_BIN") + + return os.path.join(os.path.dirname(executable), "gptscript") diff --git a/gptscript/install.py b/gptscript/install.py index 08543ba..80bd1ca 100644 --- a/gptscript/install.py +++ b/gptscript/install.py @@ -1,15 +1,16 @@ #!/usr/bin/env python3 import os -import sys import platform -import requests -from tqdm import tqdm -import zipfile -import tarfile import shutil +import sys +import tarfile +import zipfile from pathlib import Path +import requests +from tqdm import tqdm + # Define platform-specific variables platform_name = platform.system().lower() @@ -26,7 +27,7 @@ gptscript_info = { "name": "gptscript", "url": "https://github.com/gptscript-ai/gptscript/releases/download/", - "version": "v0.7.1", + "version": "v0.8.3", } pltfm = {"windows": "windows", "linux": "linux", "darwin": "macOS"}.get( diff --git a/gptscript/opts.py b/gptscript/opts.py new file mode 100644 index 0000000..974bda2 --- /dev/null +++ b/gptscript/opts.py @@ -0,0 +1,46 @@ +import os +from typing import Mapping + + +class GlobalOptions: + def __init__(self, apiKey: str = "", baseURL: str = "", defaultModel: str = "", env: Mapping[str, str] = None): + self.APIKey = apiKey + self.BaseURL = baseURL + self.DefaultModel = defaultModel + self.Env = env + + def toEnv(self): + if self.Env is None: + self.Env = os.environ.copy() + + if self.APIKey != "": + self.Env["OPENAI_API_KEY"] = self.APIKey + if self.BaseURL != "": + self.Env["OPENAI_BASE_URL"] = self.BaseURL + if self.DefaultModel != "": + self.Env["GPTSCRIPT_DEFAULT_MODEL"] = self.DefaultModel + + +class Options(GlobalOptions): + def __init__(self, + input: str = "", + disableCache: bool = False, + subTool: str = "", + workspace: str = "", + chatState: str = "", + confirm: bool = False, + prompt: bool = False, + env: list[str] = None, + apiKey: str = "", + baseURL: str = "", + defaultModel: str = "" + ): + super().__init__(apiKey, baseURL, defaultModel) + self.input = input + self.disableCache = disableCache + self.subTool = subTool + self.workspace = workspace + self.chatState = chatState + self.confirm = confirm + self.prompt = prompt + self.env = env diff --git a/gptscript/prompt.py b/gptscript/prompt.py new file mode 100644 index 0000000..3765fad --- /dev/null +++ b/gptscript/prompt.py @@ -0,0 +1,7 @@ +class PromptResponse: + def __init__(self, + id: str = "", + responses: dict[str, str] = None, + ): + self.id = id + self.responses = responses diff --git a/gptscript/run.py b/gptscript/run.py new file mode 100644 index 0000000..ad09ffe --- /dev/null +++ b/gptscript/run.py @@ -0,0 +1,190 @@ +import asyncio +import json +from typing import Union, Any, Self, Callable, Awaitable + +import httpx + +from gptscript.frame import PromptFrame, RunFrame, CallFrame, RunState, RunEventType, Program +from gptscript.opts import Options +from gptscript.tool import ToolDef, Tool + + +class Run: + def __init__(self, subCommand: str, tools: Union[ToolDef | list[ToolDef] | str], opts: Options, gptscriptURL: str, + event_handlers: list[Callable[[Self, CallFrame | RunFrame | PromptFrame], Awaitable[None]]] = None): + self.requestPath = subCommand + self.tools = tools + self.gptscriptURL = gptscriptURL + self.event_handlers = event_handlers + self.opts = opts + if self.opts is None: + self.opts = Options() + + self._state = RunState.Creating + + self.chatState: str = "" + self._output: str = "" + self._errput: str = "" + self._err: str = "" + self._aborted: bool = False + self._program: Program | None = None + self._calls: dict[str, CallFrame] | None = None + self._parentCallID: str = "" + self._rawOutput: Any = None + self._task: Awaitable | None = None + self._resp: httpx.Response | None = None + + def program(self): + return self._program + + def calls(self): + return self._calls + + def parentCallID(self): + return self._parentCallID + + def errOutput(self) -> str: + return self._errput + + async def text(self) -> str: + try: + if self._task is not None: + await self._task + except Exception: + self._state = RunState.Error + if self._aborted: + self._err = "Run was aborted" + finally: + self._task = None + + return f"run encountered an error: {self._err} with error output: {self._errput}" if self._err != "" else self._output + + def err(self): + return self._err + + def state(self): + return self._state + + def next_chat(self, input: str = "") -> Self: + if self._state != RunState.Continue and self._state != RunState.Creating and self._state != RunState.Error: + raise Exception(f"Run must in creating, continue or error state, not {self._state}") + + run = self + if run.state != RunState.Creating: + run = type(self)(self.requestPath, self.tools, self.opts, self.gptscriptURL, + event_handlers=self.event_handlers) + + if self.chatState and self._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. + run.opts.chatState = self.chatState + + run.opts.input = input + if isinstance(run.tools, list): + run._task = asyncio.create_task( + run._request({"toolDefs": [tool.to_json() for tool in run.tools], **vars(run.opts)})) + elif isinstance(run.tools, str) and run.tools != "": + run._task = asyncio.create_task(run._request({"file": run.tools, **vars(run.opts)})) + elif isinstance(run.tools, ToolDef) or isinstance(run.tools, Tool): + # In this last case, this.tools is a single ToolDef. + run._task = asyncio.create_task(run._request({"toolDefs": [run.tools.to_json()], **vars(run.opts)})) + else: + run._task = asyncio.create_task(run._request({**vars(run.opts)})) + + return run + + async def _request(self, tool: Any): + if self._state.is_terminal(): + raise Exception("run is in terminal state and cannot be run again: state " + str(self._state)) + + # Use a timeout of 15 minutes = 15 * 60s. + async with httpx.AsyncClient(timeout=httpx.Timeout(15 * 60.0)) as client: + method = "GET" if tool is None else "POST" + + async with client.stream(method, self.gptscriptURL + "/" + self.requestPath, json=tool) as resp: + self._resp = resp + self._state = RunState.Running + done = True + if resp.status_code < 200 or resp.status_code >= 400: + self._state = RunState.Error + self._err = "run encountered an error" + + async for line in resp.aiter_lines(): + line = line.strip() + line = line.removeprefix("data: ") + line = line.strip() + if line == "" or line == "[DONE]": + continue + + data = json.loads(line) + + if "stdout" in data: + if isinstance(data["stdout"], str): + self._output = data["stdout"] + else: + if isinstance(self, RunBasicCommand): + self._output = json.dumps(data["stdout"]) + else: + self.chatState = json.dumps(data["stdout"]["state"]) + if "content" in data["stdout"]: + self._output = data["stdout"]["content"] + + done = data["stdout"].get("done", False) + self._rawOutput = data["stdout"] + elif "stderr" in data: + self._errput += data["stderr"] + else: + if "prompt" in data: + event = PromptFrame(**data["prompt"]) + + # If a prmpt happens, but the call didn't explicitly allow it, then we error. + if not self.opts.prompt: + self._err = f"prompt event occurred when prompt was not allowed: {event.__dict__}" + await self.aclose() + break + elif "run" in data: + event = RunFrame(**data["run"]) + if event.type == RunEventType.runStart: + self._program = event.program + elif event.type == RunEventType.runFinish and event.error != "": + self._err = event.error + else: + event = CallFrame(**data["call"]) + if self._calls is None: + self._calls = {} + self._calls[event.id] = event + if event.parentID == "" and self._parentCallID == "": + self._parentCallID = event.id + if self.event_handlers is not None: + for event_handler in self.event_handlers: + asyncio.create_task(event_handler(self, event)) + + self._resp = None + if self._err != "": + self._state = RunState.Error + elif done: + self._state = RunState.Finished + else: + self._state = RunState.Continue + + async def aclose(self): + if self._task is None or self._resp is None: + raise Exception("run not started") + elif not self._aborted: + self._aborted = True + await self._resp.aclose() + + +class RunBasicCommand(Run): + def __init__(self, subCommand: str, request_body: Any, gptscriptURL: str): + super().__init__(subCommand, "", Options(), gptscriptURL) + self.request_body = request_body + + def next_chat(self, input: str = "") -> Self: + if self._state != RunState.Creating: + raise Exception(f"A basic command run must in creating, not {self._state}") + + self.opts.input = input + self._task = self._request(self.request_body) + + return self diff --git a/gptscript/text.py b/gptscript/text.py new file mode 100644 index 0000000..59d8d9c --- /dev/null +++ b/gptscript/text.py @@ -0,0 +1,21 @@ +from typing import Any + + +class Text: + def __init__(self, + fmt: str = "", + text: str = "", + ): + if fmt == "" and text.startswith("!"): + fmt = text[1:text.index("\n")] + text = text.removeprefix("!" + fmt).strip() + + self.format = fmt + self.text = text + + def to_json(self) -> dict[str, Any]: + text = self.text + "\n" + if self.format != "": + text = "!" + self.format + "\n" + text + + return {"textNode": {"text": text}} diff --git a/gptscript/tool.py b/gptscript/tool.py index c948afa..1b8efd4 100644 --- a/gptscript/tool.py +++ b/gptscript/tool.py @@ -1,63 +1,201 @@ -class Tool: - def __init__( - self, - name="", - description="", - tools=[], - max_tokens=None, - model="", - cache=True, - temperature=None, - args={}, - internal_prompt=False, - instructions="", - json_response=False, - ): +from typing import Any + + +class Property: + def __init__(self, + type: str = "string", + description: str = "", + default: str = "", + ): + self.type = type + self.description = description + self.default = default + + def to_json(self): + return self.__dict__ + + +class ArgumentSchema: + def __init__(self, + type: str = "object", + properties: dict[str, Property] = None, + required: list[str] = None, + ): + self.type = type + self.properties = properties + if self.properties is not None: + for prop in self.properties: + if isinstance(self.properties[prop], dict): + self.properties[prop] = Property(**self.properties[prop]) + self.required = required + + def to_json(self): + out = self.__dict__ + for prop in self.properties: + out["properties"][prop] = self.properties[prop].to_json() + + return out + + +class ToolDef: + def __init__(self, + name: str = "", + description: str = "", + maxTokens: int = 0, + modelName: str = "", + modelProvider: bool = False, + jsonResponse: bool = False, + temperature: int | None = None, + cache: bool | None = None, + chat: bool = False, + internalPrompt: bool | None = None, + arguments: ArgumentSchema = None, + tools: list[str] = None, + globalTools: list[str] = None, + globalModelName: str = "", + context: list[str] = None, + exportContext: list[str] = None, + export: list[str] = None, + agents: list[str] = None, + credentials: list[str] = None, + instructions: str = "", + ): self.name = name self.description = description - self.tools = tools - self.max_tokens = max_tokens - self.model = model - self.cache = cache + self.maxTokens = maxTokens + self.modelName = modelName + self.modelProvider = modelProvider + self.jsonResponse = jsonResponse self.temperature = temperature - self.args = args - self.internal_prompt = internal_prompt + self.cache = cache + self.chat = chat + self.internalPrompt = internalPrompt + self.arguments = arguments + if self.arguments is not None: + if isinstance(self.arguments, dict): + self.arguments = ArgumentSchema(**self.arguments) + self.tools = tools + self.globalTools = globalTools + self.globalModelName = globalModelName + self.context = context + self.exportContext = exportContext + self.export = export + self.agents = agents + self.credentials = credentials self.instructions = instructions - self.json_response = json_response - - def __str__(self): - tool = "" - if self.name != "": - tool += f"Name: {self.name}\n" - if self.description != "": - tool += f"Description: {self.description}\n" - if len(self.tools) > 0 and self.tools: - tools = ", ".join(self.tools) - tool += f"Tools: {tools}\n" - if self.max_tokens is not None: - tool += f"Max tokens: {self.max_tokens}\n" - if self.model != "": - tool += f"Model: {self.model}\n" - if not self.cache: - tool += "Cache: false\n" - if self.temperature is not None: - tool += f"Temperature: {self.temperature}\n" - if self.json_response: - tool += "JSON Response: true\n" - if self.args: - for arg, desc in self.args.items(): - tool += f"Args: {arg}: {desc}\n" - if self.internal_prompt: - tool += f"Internal prompt: {self.internal_prompt}\n" - if self.instructions != "": - tool += self.instructions - - return tool - - -class FreeForm: - def __init__(self, content=""): - self.content = content - - def __str__(self): - return self.content + + def to_json(self) -> dict[str, Any]: + out = self.__dict__ + if self.arguments is not None: + out["arguments"] = self.arguments.to_json() + return out + + +class ToolReference: + def __init__(self, + named: str = "", + reference: str = "", + arg: str = "", + toolID: str = "", + ): + self.named = named + self.reference = reference + self.arg = arg + self.toolID = toolID + + def to_json(self) -> dict[str, Any]: + return self.__dict__ + + +class Repo: + def __init__(self, + VCS: str = "", + Root: str = "", + Path: str = "", + Name: str = "", + Revision: str = "", + ): + self.VCS = VCS + self.Root = Root + self.Path = Path + self.Name = Name + self.Revision = Revision + + +class SourceRef: + def __init__(self, + location: str = "", + lineNo: int = 0, + repo: Repo = None, + ): + self.location = location + self.lineNo = lineNo + self.repo = repo + if self.repo is not None and isinstance(self.repo, dict): + self.repo = Repo(**self.repo) + + def to_json(self) -> dict[str, Any]: + return self.__dict__ + + +class Tool(ToolDef): + def __init__(self, + id: str = "", + name: str = "", + description: str = "", + maxTokens: int = 0, + modelName: str = "", + modelProvider: bool = False, + jsonResponse: bool = False, + temperature: int | None = None, + cache: bool | None = None, + chat: bool = False, + internalPrompt: bool | None = None, + arguments: ArgumentSchema = None, + tools: list[str] = None, + globalTools: list[str] = None, + globalModelName: str = "", + context: list[str] = None, + exportContext: list[str] = None, + export: list[str] = None, + agents: list[str] = None, + credentials: list[str] = None, + instructions: str = "", + toolMapping: dict[str, list[ToolReference]] = None, + localTools: dict[str, str] = None, + source: SourceRef = None, + workingDir: str = "", + ): + super().__init__(name, description, maxTokens, modelName, modelProvider, jsonResponse, temperature, cache, chat, + internalPrompt, arguments, tools, globalTools, globalModelName, context, exportContext, export, + agents, credentials, instructions) + + self.id = id + self.toolMapping = toolMapping + if self.toolMapping is not None: + for tool in self.toolMapping: + if self.toolMapping[tool] is not None: + for i in range(len(self.toolMapping[tool])): + if isinstance(self.toolMapping[tool][i], dict): + self.toolMapping[tool][i] = ToolReference(**self.toolMapping[tool][i]) + self.localTools = localTools + self.source = source + if self.source is not None and isinstance(self.source, dict): + self.source = SourceRef(**self.source) + self.workingDir = workingDir + + def to_json(self) -> Any: + tool_dict = super().to_json() + tool_dict["id"] = self.id + tool_dict["workingDir"] = self.workingDir + tool_dict["localTools"] = self.localTools + + if self.toolMapping is not None: + for tool_map in self.toolMapping: + for i in range(len(self.toolMapping[tool_map])): + tool_dict["toolMapping"][tool_map][i] = self.toolMapping[tool_map][i].to_json() + + if self.source is not None: + tool_dict["source"] = self.source.to_json() + + return {"toolNode": {"tool": tool_dict}} diff --git a/pyproject.toml b/pyproject.toml index fadf4f5..e906478 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gptscript" -version = "0.5.0" +version = "0.8.3" description = "Run gptscripts from Python apps" readme = "README.md" authors = [{ name = "Bill Maxwell", email = "bill@acorn.io" }] diff --git a/requirements.txt b/requirements.txt index bbc80ea..03e1509 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ wheel==0.42.0 setuptools==69.1.1 twine==5.0.0 build==1.1.1 +httpx==0.27.0 pywin32==306; sys_platform == 'win32' \ No newline at end of file diff --git a/tests/fixtures/acorn-labs-context.gpt b/tests/fixtures/acorn-labs-context.gpt new file mode 100644 index 0000000..e4dbe2c --- /dev/null +++ b/tests/fixtures/acorn-labs-context.gpt @@ -0,0 +1 @@ +Say "Answer every query with 'Acorn Labs'" \ No newline at end of file diff --git a/tests/fixtures/chat.gpt b/tests/fixtures/chat.gpt new file mode 100644 index 0000000..d47e244 --- /dev/null +++ b/tests/fixtures/chat.gpt @@ -0,0 +1,4 @@ +chat: true +tools: sys.chat.finish + +You are a chat bot. Don't finish the conversation until I say 'bye'. \ No newline at end of file diff --git a/tests/fixtures/global-tools.gpt b/tests/fixtures/global-tools.gpt new file mode 100644 index 0000000..cb0f4c0 --- /dev/null +++ b/tests/fixtures/global-tools.gpt @@ -0,0 +1,19 @@ +!title + +Runbook 3 + +--- +Name: tool_1 +Global Tools: 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 + +Hi + +--- +Name: tool_2 + +What time is it? + +--- +Name: tool_3 + +Give me a paragraph of lorem ipsum \ No newline at end of file diff --git a/tests/fixtures/test.gpt b/tests/fixtures/test.gpt index 1cedfa3..d3d6034 100644 --- a/tests/fixtures/test.gpt +++ b/tests/fixtures/test.gpt @@ -1 +1 @@ -Respond with a hello, in a random language. Also include the language in the response. +Who was the president of the United States in 1986? \ No newline at end of file diff --git a/tests/test_gptscript.py b/tests/test_gptscript.py index 2e632fe..4b35c59 100644 --- a/tests/test_gptscript.py +++ b/tests/test_gptscript.py @@ -1,44 +1,44 @@ import os +import platform import pytest -from gptscript.command import ( - version, - list_models, - list_tools, - exec, - exec_file, - stream_exec, - stream_exec_with_events, - stream_exec_file, - stream_exec_file_with_events, -) -from gptscript.tool import Tool, FreeForm +from gptscript.confirm import AuthResponse +from gptscript.frame import RunEventType, CallFrame, RunFrame, RunState, PromptFrame +from gptscript.gptscript import GPTScript +from gptscript.opts import GlobalOptions, Options +from gptscript.prompt import PromptResponse +from gptscript.run import Run +from gptscript.text import Text +from gptscript.tool import ToolDef, ArgumentSchema, Property, Tool # Ensure the OPENAI_API_KEY is set for testing @pytest.fixture(scope="session", autouse=True) -def check_api_key(): +def gptscript(): if os.getenv("OPENAI_API_KEY") is None: pytest.fail("OPENAI_API_KEY not set", pytrace=False) + try: + gptscript = GPTScript(GlobalOptions(apiKey=os.getenv("OPENAI_API_KEY"))) + return gptscript + except Exception as e: + pytest.fail(e, pytrace=False) # Simple tool for testing @pytest.fixture def simple_tool(): - return FreeForm( - content=""" -What is the capital of the united states? -""" + return ToolDef( + instructions="What is the capital of the united states?" ) # Complex tool for testing @pytest.fixture def complex_tool(): - return Tool( + return ToolDef( tools=["sys.write"], - json_response=True, + jsonResponse=True, instructions=""" Create three short graphic artist descriptions and their muses. These should be descriptive and explain their point of view. @@ -58,124 +58,432 @@ def complex_tool(): # Fixture for a list of tools @pytest.fixture def tool_list(): + shebang = "#!/bin/bash" + if platform.system() == "windows": + shebang = "#!/usr/bin/env powershell.exe" return [ - Tool(tools=["echo"], instructions="echo hello there"), - Tool(name="other", tools=["echo"], instructions="echo hello somewhere else"), - Tool( + ToolDef(tools=["echo"], instructions="echo 'hello there'"), + ToolDef(name="other", tools=["echo"], instructions="echo 'hello somewhere else'"), + ToolDef( name="echo", tools=["sys.exec"], - description="Echo's the input", - args={"input": "the string input to echo"}, - instructions=""" - #!/bin/bash - echo ${input} - """, + description="Echoes the input", + arguments=ArgumentSchema(properties={"input": Property("The string input to echo")}), + instructions=f""" +${shebang} +echo ${input} +""", ), ] -def test_version(): - v = version() +@pytest.mark.asyncio +async def test_create_another_gptscript(): + g = GPTScript() + version = await g.version() + g.close() + assert "gptscript version" in version + + +@pytest.mark.asyncio +async def test_version(gptscript): + v = await gptscript.version() assert "gptscript version " in v -# Test function for listing models -def test_list_models(): - models = list_models() - assert isinstance(models, list), "Expected list_models to return a list" +@pytest.mark.asyncio +async def test_list_models(gptscript): + models = await gptscript.list_models() + assert isinstance(models, list) and len(models) > 1, "Expected list_models to return a list" -# Test function for listing tools -def test_list_tools(): - out = list_tools() +@pytest.mark.asyncio +async def test_list_tools(gptscript): + out = await gptscript.list_tools() assert out is not None, "Expected some output from list_tools" -# Test execution of a simple tool -def test_exec_simple_tool(simple_tool): - out, err = exec(simple_tool) - assert out is not None, "Expected some output from exec using simple_tool" +@pytest.mark.asyncio +async def test_abort_run(gptscript): + async def about_run(run: Run, e: CallFrame | RunFrame | PromptFrame): + await run.aclose() + run = gptscript.evaluate(ToolDef(instructions="What is the capital of the united states?"), + Options(disableCache=True), event_handlers=[about_run]) -# Test execution of a complex tool -def test_exec_complex_tool(complex_tool): - opts = {"cache": False} - out, err = exec(complex_tool, opts=opts) - assert out is not None, "Expected some output from exec using complex_tool" + assert "Run was aborted" in await run.text(), "Unexpected output from abort_run" + assert RunState.Error == run.state(), "Unexpected run state after aborting" -# Test execution with a list of tools -def test_exec_tool_list(tool_list): - out, err = exec(tool_list) - assert out.strip() == "hello there", "Unexpected output from exec using a list of tools" +@pytest.mark.asyncio +async def test_eval_simple_tool(gptscript, simple_tool): + run = gptscript.evaluate(simple_tool) + out = await run.text() + assert "Washington" in out, "Unexpected response for tool run" -def test_exec_tool_list_with_sub_tool(tool_list): - out, err = exec(tool_list, opts={"subTool": "other"}) - assert out.strip() == "hello somewhere else", "Unexpected output from exec using a list of tools with sub tool" +@pytest.mark.asyncio +async def test_eval_complex_tool(gptscript, complex_tool): + run = gptscript.evaluate(complex_tool, Options(disableCache=True)) + out = await run.text() + assert '"artists":' in out, "Expected some output from eval using complex_tool" -# Test streaming execution of a complex tool -def test_stream_exec_complex_tool(complex_tool): - out, err, wait = stream_exec(complex_tool) - resp = wait() # Wait for streaming to complete - assert ( - out is not None or err is not None - ), "Expected some output or error from stream_exec using complex_tool" - assert ( - resp == 0 - ), "Expected a successful response from stream_exec using complex_tool" +@pytest.mark.asyncio +async def test_eval_tool_list(gptscript, tool_list): + run = gptscript.evaluate(tool_list) + out = await run.text() + assert out.strip() == "hello there", "Unexpected output from eval using a list of tools" -def test_exec_file_with_chdir(): - # By changing the directory here, we should be able to find the test.gpt file without `./tests` - out, err = exec_file("./test.gpt", opts={"chdir": "./tests/fixtures"}) - for line in out: - print(line) - for line in err: - print(line) - assert ( - out is not None and err is not None - ), "Expected some output or error from stream_exec_file" - - -# Test streaming execution from a file -def test_stream_exec_file(): - out, err, wait = stream_exec_file("./tests/fixtures/test.gpt") - resp = wait() # Wait for streaming to complete - for line in out: - print(line) - for line in err: - print(line) - assert ( - out is not None or err is not None - ), "Expected some output or error from stream_exec_file" - assert resp == 0, "Expected a successful response from stream_exec_file" +@pytest.mark.asyncio +async def test_eval_tool_list_with_sub_tool(gptscript, tool_list): + run = gptscript.evaluate(tool_list, opts=Options(subTool="other")) + out = await run.text() + assert out.strip() == "hello somewhere else", "Unexpected output from eval using a list of tools with sub tool" -def test_stream_exec_tool_with_events(simple_tool): - out, err, events, wait = stream_exec_with_events(simple_tool) - has_events = False - for line in events: - has_events = line != "" +@pytest.mark.asyncio +async def test_stream_exec_complex_tool(gptscript, complex_tool): + stream_output = "" - assert has_events, "Expected some events from stream_exec_with_events" - resp = wait() # Wait for streaming to complete - assert ( - out is not None or err is not None - ), "Expected some output or error from stream_exec_file" - assert resp == 0, "Expected a successful response from stream_exec_file" + async def collect_events(run: Run, e: CallFrame | RunFrame | PromptFrame): + nonlocal stream_output + if str(e.type.name).startswith("call") and e.output is not None: + for output in e.output: + stream_output += output.content + + run = gptscript.evaluate(complex_tool, Options(disableCache=True), event_handlers=[collect_events]) + out = await run.text() + assert '"artists":' in out, "Expected some output from streaming using complex_tool" + assert '"artists":' in stream_output, "Expected stream_output to have output" + + +@pytest.mark.asyncio +async def test_stream_run_file(gptscript): + stream_output = "" + + async def collect_events(run: Run, e: CallFrame | RunFrame | PromptFrame): + nonlocal stream_output + if str(e.type.name).startswith("call") and e.output is not None: + for output in e.output: + stream_output += output.content + + run = gptscript.run("./tests/fixtures/test.gpt", Options(disableCache=True), event_handlers=[collect_events]) + assert "Ronald Reagan" in await run.text(), "Expect streaming file to have correct output" + assert "Ronald Reagan" in stream_output, "Expect stream_output to have correct output when streaming from file" + + +@pytest.mark.asyncio +async def test_eval_with_context(gptscript): + wd = os.getcwd() + tool = ToolDef( + instructions="What is the capital of the united states?", + context=[wd + "/tests/fixtures/acorn-labs-context.gpt"], + ) + + run = gptscript.evaluate(tool) + + assert "Acorn Labs" == await run.text(), "Unexpected output from eval using context" + + +@pytest.mark.asyncio +async def test_parse_simple_file(gptscript): + wd = os.getcwd() + tools = await gptscript.parse(wd + "/tests/fixtures/test.gpt") + assert len(tools) == 1, "Unexpected number of tools for parsing simple file" + assert isinstance(tools[0], Tool), "Unexpected node type from parsing simple file" + assert tools[0].instructions == "Who was the president of the United States in 1986?", \ + "Unexpected output from parsing simple file" + + +@pytest.mark.asyncio +async def test_parse_tool(gptscript): + tools = await gptscript.parse_tool("echo hello") + assert len(tools) == 1, "Unexpected number of tools for parsing tool" + assert isinstance(tools[0], Tool), "Unexpected node type from parsing tool" + assert tools[0].instructions == "echo hello", "Unexpected output from parsing tool" + + +@pytest.mark.asyncio +async def test_parse_tool_with_text_node(gptscript): + tools = await gptscript.parse_tool("echo hello\n---\n!markdown\nhello") + assert len(tools) == 2, "Unexpected number of tools for parsing tool with text node" + assert isinstance(tools[0], Tool), "Unexpected node type for first tool from parsing tool with text node" + assert isinstance(tools[1], Text), "Unexpected node type for second tool from parsing tool with text node" + assert tools[0].instructions == "echo hello", "Unexpected instructions from parsing tool with text node" + assert tools[1].text == "hello", "Unexpected text node text from parsing tool with text node" + assert tools[1].format == "markdown", "Unexpected text node fmt from parsing tool with text node" + + +@pytest.mark.asyncio +async def test_fmt(gptscript): + nodes = [ + Tool(tools=["echo"], instructions="echo hello there"), + Tool( + name="echo", + instructions="#!/bin/bash\necho hello there", + arguments=ArgumentSchema( + properties={"input": Property(description="The string input to echo")}, + ) + ) + ] + + expected_output = """Tools: echo +echo hello there -def test_stream_exec_file_with_events(): - out, err, events, wait = stream_exec_file_with_events("./tests/fixtures/test.gpt") - has_events = False - for line in events: - has_events = line != "" +--- +Name: echo +Parameter: input: The string input to echo - assert has_events, "Expected some events from stream_exec_file_with_events" - resp = wait() # Wait for streaming to complete +#!/bin/bash +echo hello there +""" + assert await gptscript.fmt(nodes) == expected_output, "Unexpected output from fmt using nodes" + + +@pytest.mark.asyncio +async def test_fmt_with_text_node(gptscript): + nodes = [ + Tool(tools=["echo"], instructions="echo hello there"), + Text(fmt="markdown", text="We now echo hello there"), + Tool( + name="echo", + instructions="#!/bin/bash\necho hello there", + arguments=ArgumentSchema( + properties={"input": Property(description="The string input to echo")}, + ) + ) + ] + + expected_output = """Tools: echo + +echo hello there + +--- +!markdown +We now echo hello there +--- +Name: echo +Parameter: input: The string input to echo + +#!/bin/bash +echo hello there +""" + + assert await gptscript.fmt(nodes) == expected_output, "Unexpected output from fmt using nodes" + + +@pytest.mark.asyncio +async def test_tool_chat(gptscript): + tool = ToolDef( + chat=True, + instructions="You are a chat bot. Don't finish the conversation until I say 'bye'.", + tools=["sys.chat.finish"], + ) + + 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?", + ] + expected_outputs = [ + "California", + "Sacramento", + "Alaska Time Zone", + ] + + run = gptscript.evaluate(tool) + await run.text() + assert run.state() == RunState.Continue, "first run in unexpected state" + + for i in range(len(inputs)): + run = run.next_chat(inputs[i]) + + output = await run.text() + assert run.state() == RunState.Continue, "run in unexpected state" + assert expected_outputs[i] in output, "unexpected output for chat" + + +@pytest.mark.asyncio +async def test_file_chat(gptscript): + 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?", + ] + expected_outputs = [ + "Lake Superior", + "Lake Michigan", + "Lake Huron", + ] + + run = gptscript.run(os.getcwd() + "/tests/fixtures/chat.gpt") + await run.text() + assert run.state() == RunState.Continue, "first run in unexpected state" + + for i in range(len(inputs)): + run = run.next_chat(inputs[i]) + + output = await run.text() + assert run.state() == RunState.Continue, "run in unexpected state" + assert expected_outputs[i] in output, "unexpected output for chat" + + +@pytest.mark.asyncio +async def test_global_tools(gptscript): + run_start_seen = False + call_start_seen = False + call_progress_seen = False + call_finish_seen = False + run_finish_seen = False + event_output = "" + + async def process_event(r: Run, frame: CallFrame | RunFrame | PromptFrame): + nonlocal run_start_seen, call_start_seen, call_progress_seen, call_finish_seen, run_finish_seen, event_output + if isinstance(frame, RunFrame): + if frame.type == RunEventType.runStart: + run_start_seen = True + elif frame.type == RunEventType.runFinish: + run_finish_seen = True + else: + if frame.type == RunEventType.callStart: + call_start_seen = True + elif frame.type == RunEventType.callProgress: + call_progress_seen = True + for output in frame.output: + event_output += output.content + elif frame.type == RunEventType.callFinish: + call_finish_seen = True + + run = gptscript.run(os.getcwd() + "/tests/fixtures/global-tools.gpt", + Options(disableCache=True), + event_handlers=[process_event], + ) + + assert "Hello!" in await run.text(), "Unexpected output from global tool test" + assert "Hello" in event_output, "Unexpected stream output from global tool test" + + assert run_start_seen and call_start_seen and call_progress_seen and call_finish_seen and run_finish_seen, \ + f"One of these is False: {run_start_seen}, {call_start_seen}, {call_progress_seen}, {call_finish_seen}, {run_finish_seen}" + + +@pytest.mark.asyncio +async def test_confirm(gptscript): + confirm_event_found = False + event_content = "" + + async def process_event(r: Run, frame: CallFrame | RunFrame | PromptFrame): + nonlocal confirm_event_found, event_content + if frame.type == RunEventType.callConfirm: + confirm_event_found = True + assert '"ls"' in frame.input or '"dir"' in frame.input, "Unexpected confirm input: " + frame.input + await gptscript.confirm(AuthResponse(frame.id, True)) + elif frame.type == RunEventType.callProgress: + for output in frame.output: + event_content += output.content + + tool = ToolDef(tools=["sys.exec"], instructions="List the files in the current directory.") + out = await gptscript.evaluate(tool, + Options(confirm=True, disableCache=True), + event_handlers=[process_event], + ).text() + + assert confirm_event_found, "No confirm event" + # Running the `dir` command in Windows will give the contents of the tests directory + # while running `ls` on linux will give the contents of the repo directory. assert ( - out is not None or err is not None - ), "Expected some output or error from stream_exec_file" - assert resp == 0, "Expected a successful response from stream_exec_file" + "README.md" in out and "requirements.txt" in out + ) or ( + "fixtures" in out and "test_gptscript.py" in out + ), "Unexpected output: " + out + assert ( + "README.md" in event_content and "requirements.txt" in event_content + ) or ( + "fixtures" in event_content and "test_gptscript.py" in event_content + ), "Unexpected event output: " + event_content + + +@pytest.mark.asyncio +async def test_confirm_deny(gptscript): + confirm_event_found = False + event_content = "" + + async def process_event(r: Run, frame: CallFrame | RunFrame | PromptFrame): + nonlocal confirm_event_found, event_content + if frame.type == RunEventType.callConfirm: + confirm_event_found = True + assert '"ls"' in frame.input, "Unexpected confirm input: " + frame.input + await gptscript.confirm(AuthResponse(frame.id, False, "I will not allow it!")) + elif frame.type == RunEventType.callProgress: + for output in frame.output: + event_content += output.content + + tool = ToolDef(tools=["sys.exec"], instructions="List the files in the current directory.") + out = await gptscript.evaluate(tool, + Options(confirm=True, disableCache=True), + event_handlers=[process_event], + ).text() + + assert confirm_event_found, "No confirm event" + assert "authorization error" in out, "Unexpected output: " + out + assert "authorization error" in event_content, "Unexpected event output: " + event_content + + +@pytest.mark.asyncio +async def test_prompt(gptscript): + prompt_event_found = False + event_content = "" + + async def process_event(r: Run, frame: CallFrame | RunFrame | PromptFrame): + nonlocal prompt_event_found, event_content + if frame.type == RunEventType.prompt: + prompt_event_found = True + assert len(frame.fields) == 1, "Unexpected number of fields: " + str(frame.fields) + assert "first name" in frame.fields[0], "Unexpected field: " + frame.fields[0] + await gptscript.prompt(PromptResponse(frame.id, {frame.fields[0]: "Clicky"})) + elif frame.type == RunEventType.callProgress: + for output in frame.output: + event_content += output.content + + tool = ToolDef( + tools=["sys.prompt"], + 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.", + ) + out = await gptscript.evaluate( + tool, + Options(prompt=True, disableCache=True), + event_handlers=[process_event], + ).text() + + assert prompt_event_found, "No prompt event" + assert "Clicky" in out, "Unexpected output: " + out + assert "Clicky" in event_content, "Unexpected event output: " + event_content + + +@pytest.mark.asyncio +async def test_prompt_without_prompt_allowed(gptscript): + prompt_event_found = False + + async def process_event(r: Run, frame: CallFrame | RunFrame | PromptFrame): + nonlocal prompt_event_found + if frame.type == RunEventType.prompt: + prompt_event_found = True + assert len(frame.fields) == 1, "Unexpected number of fields: " + str(frame.fields) + assert "first name" in frame.fields[0], "Unexpected field: " + frame.fields[0] + await gptscript.prompt(PromptResponse(frame.id, {frame.fields[0]: "Clicky"})) + + tool = ToolDef( + tools=["sys.prompt"], + 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.", + ) + run = gptscript.evaluate( + tool, + event_handlers=[process_event], + ) + + out = await run.text() + + assert not prompt_event_found, "Prompt event occurred" + assert "prompt event occurred" in out, "Unexpected output: " + out diff --git a/tox.ini b/tox.ini index 0089c6e..491f01e 100644 --- a/tox.ini +++ b/tox.ini @@ -2,9 +2,13 @@ envlist = py3 [testenv] -deps = pytest +deps = httpx + pytest-asyncio -passenv = OPENAI_API_KEY, GPTSCRIPT_BIN +passenv = OPENAI_API_KEY + GPTSCRIPT_BIN + GPTSCRIPT_URL + GPTSCRIPT_DISABLE_SERVER commands = install_gptscript - pytest tests/ + pytest -s tests/