diff --git a/.gitignore b/.gitignore index fa8c9d6..c6a2326 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,6 @@ go.work .idea/ .vscode/ +workspace/ bin/ workspace/ diff --git a/README.md b/README.md index d151052..66f2ceb 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,14 @@ To use the module, you need to first set the OPENAI_API_KEY environment variable Additionally, you need the `gptscript` binary. You can install it on your system using the [installation instructions](https://github.com/gptscript-ai/gptscript?tab=readme-ov-file#1-install-the-latest-release). The binary can be on the PATH, or the `GPTSCRIPT_BIN` environment variable can be used to specify its location. +## Client + +There are currently a couple "global" options, and the client helps to manage those. A client without any options is +likely what you want. However, here are the current global options: + +- `gptscriptURL`: The URL (including `http(s)://) of an "SDK server" to use instead of the fork/exec model. +- `gptscriptBin`: The path to a `gptscript` binary to use instead of the bundled one. + ## Options These are optional options that can be passed to the various `exec` functions. @@ -26,6 +34,10 @@ None of the options is required, and the defaults will reduce the number of call - `quiet`: No output logging - `chdir`: Change current working directory - `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 +- `inlcudeEvents`: Whether to include the streaming of events. Default (false). Note that if this is true, you must stream the events. See below for details. +- `chatState`: The chat state to continue, or null to start a new chat and return the state ## Functions @@ -45,7 +57,9 @@ import ( ) func listTools(ctx context.Context) (string, error) { - return gogptscript.ListTools(ctx) + client := &gogptscript.Client{} + client.Complete() + return client.ListTools(ctx) } ``` @@ -65,13 +79,15 @@ import ( ) func listModels(ctx context.Context) ([]string, error) { - return gogptscript.ListModels(ctx) + client := &gogptscript.Client{} + client.Complete() + return client.ListModels(ctx) } ``` -### ExecTool +### Parse -Executes a prompt with optional arguments. +Parse file into a Tool data structure ```go package main @@ -82,18 +98,17 @@ import ( gogptscript "github.com/gptscript-ai/go-gptscript" ) -func runTool(ctx context.Context) (string, error) { - t := gogptscript.Tool{ - Instructions: "who was the president of the united states in 1928?", - } - - return gogptscript.ExecTool(ctx, gogptscript.Opts{}, t) +func parse(ctx context.Context, fileName string) ([]gogptscript.Node, error) { + client := &gogptscript.Client{} + client.Complete() + + return client.Parse(ctx, fileName) } ``` -### ExecFile +### ParseTool -Executes a GPT script file with optional input and arguments. The script is relative to the callers source directory. +Parse contents that represents a GPTScript file into a data structure. ```go package main @@ -104,18 +119,17 @@ import ( gogptscript "github.com/gptscript-ai/go-gptscript" ) -func execFile(ctx context.Context) (string, error) { - opts := gogptscript.Opts{ - DisableCache: &[]bool{true}[0], - } - - return gogptscript.ExecFile(ctx, "./hello.gpt", "--input World", opts) +func parseTool(ctx context.Context, contents string) ([]gogptscript.Node, error) { + client := &gogptscript.Client{} + client.Complete() + + return client.ParseTool(ctx, contents) } ``` -### StreamExecTool +### Fmt -Executes a gptscript with optional input and arguments, and returns the output streams. +Parse convert a tool data structure into a GPTScript file. ```go package main @@ -126,22 +140,17 @@ import ( gogptscript "github.com/gptscript-ai/go-gptscript" ) -func streamExecTool(ctx context.Context) error { - t := gogptscript.Tool{ - Instructions: "who was the president of the united states in 1928?", - } - - stdOut, stdErr, wait := gogptscript.StreamExecTool(ctx, gogptscript.Opts{}, t) - - // Read from stdOut and stdErr before call wait() - - return wait() +func parse(ctx context.Context, nodes []gogptscript.node) (string, error) { + client := &gogptscript.Client{} + client.Complete() + + return client.Fmt(ctx, nodes) } ``` -### StreamExecToolWithEvents +### Evaluate -Executes a gptscript with optional input and arguments, and returns the stdout, stderr, and gptscript events streams. +Executes a tool with optional arguments. ```go package main @@ -152,22 +161,26 @@ import ( gogptscript "github.com/gptscript-ai/go-gptscript" ) -func streamExecTool(ctx context.Context) error { - t := gogptscript.Tool{ +func runTool(ctx context.Context) (string, error) { + t := gogptscript.ToolDef{ Instructions: "who was the president of the united states in 1928?", } - stdOut, stdErr, events, wait := gogptscript.StreamExecToolWithEvents(ctx, gogptscript.Opts{}, t) + client := &gogptscript.Client{} + client.Complete() - // Read from stdOut and stdErr before call wait() + run, err := client.Evaluate(ctx, gogptscript.Opts{}, t) + if err != nil { + return "", err + } - return wait() + return run.Text() } ``` -### streamExecFile +### Run -The script is relative to the callers source directory. +Executes a GPT script file with optional input and arguments. The script is relative to the callers source directory. ```go package main @@ -178,22 +191,27 @@ import ( gogptscript "github.com/gptscript-ai/go-gptscript" ) -func streamExecTool(ctx context.Context) error { +func runFile(ctx context.Context) (string, error) { opts := gogptscript.Opts{ DisableCache: &[]bool{true}[0], + Input: "--input hello", } - stdOut, stdErr, wait := gogptscript.StreamExecFile(ctx, "./hello.gpt", "--input world", opts) + client := &gogptscript.Client{} + client.Complete() - // Read from stdOut and stdErr before call wait() + run, err := client.Run(ctx, "./hello.gpt", opts) + if err != nil { + return "", err + } - return wait() + return run.Text() } ``` -### streamExecFileWithEvents +### Streaming events -The script is relative to the callers source directory. +In order to stream events, you must set `IncludeEvents` option to `true`. You if you don't set this and try to stream events, then it will succeed, but you will not get any events. More importantly, if you set `IncludeEvents` to `true`, you must stream the events for the script to complete. ```go package main @@ -206,14 +224,26 @@ import ( func streamExecTool(ctx context.Context) error { opts := gogptscript.Opts{ - DisableCache: &[]bool{true}[0], + DisableCache: &[]bool{true}[0], + IncludeEvents: true, + Input: "--input world", } - stdOut, stdErr, events, wait := gogptscript.StreamExecFileWithEvents(ctx, "./hello.gpt", "--input world", opts) + client := &gogptscript.Client{} + client.Complete() - // Read from stdOut and stdErr before call wait() + run, err := client.Run(ctx, "./hello.gpt", opts) + if err != nil { + return err + } - return wait() + for event := range run.Events() { + // Process event... + } + + // Wait for the output to ensure the script completes successfully. + _, err = run.Text() + return err } ``` @@ -235,12 +265,6 @@ func streamExecTool(ctx context.Context) error { | instructions | string | `""` | Instructions on how to use the tool. | | jsonResponse | boolean | `false` | Whether the tool returns a JSON response instead of plain text. You must include the word 'json' in the body of the prompt | -### FreeForm Parameters - -| Argument | Type | Default | Description | -|-----------|--------|---------|---------------------------------------| -| content | string | `""` | This is a multi-line string that contains the entire contents of a valid gptscript file| - ## License Copyright (c) 2024, [Acorn Labs, Inc.](https://www.acorn.io) diff --git a/client.go b/client.go new file mode 100644 index 0000000..93fc6e9 --- /dev/null +++ b/client.go @@ -0,0 +1,209 @@ +package gptscript + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" +) + +const relativeToBinaryPath = "" + +type Client struct { + GPTScriptURL string + GPTScriptBin string +} + +func (c *Client) Complete() { + if c.GPTScriptBin == "" { + c.GPTScriptBin = getCommand() + } +} + +func (c *Client) Evaluate(ctx context.Context, opts Opts, tools ...fmt.Stringer) (*Run, error) { + return (&Run{ + url: c.GPTScriptURL, + binPath: c.GPTScriptBin, + requestPath: "evaluate", + state: Creating, + opts: opts, + content: concatTools(tools), + chatState: opts.ChatState, + }).NextChat(ctx, opts.Input) +} + +func (c *Client) Run(ctx context.Context, toolPath string, opts Opts) (*Run, error) { + return (&Run{ + url: c.GPTScriptURL, + binPath: c.GPTScriptBin, + requestPath: "run", + state: Creating, + opts: opts, + toolPath: toolPath, + chatState: opts.ChatState, + }).NextChat(ctx, opts.Input) +} + +// Parse will parse the given file into an array of Nodes. +func (c *Client) Parse(ctx context.Context, fileName string) ([]Node, error) { + out, err := c.runBasicCommand(ctx, "parse", "parse", fileName, "") + if err != nil { + return nil, err + } + + var doc Document + if err = json.Unmarshal([]byte(out), &doc); err != nil { + return nil, err + } + + return doc.Nodes, nil +} + +// ParseTool will parse the given string into a tool. +func (c *Client) ParseTool(ctx context.Context, toolDef string) ([]Node, error) { + out, err := c.runBasicCommand(ctx, "parse", "parse", "", toolDef) + if err != nil { + return nil, err + } + + var doc Document + if err = json.Unmarshal([]byte(out), &doc); err != nil { + return nil, err + } + + return doc.Nodes, nil +} + +// Fmt will format the given nodes into a string. +func (c *Client) Fmt(ctx context.Context, nodes []Node) (string, error) { + b, err := json.Marshal(Document{Nodes: nodes}) + if err != nil { + return "", fmt.Errorf("failed to marshal nodes: %w", err) + } + + run := &runSubCommand{ + Run: Run{ + url: c.GPTScriptURL, + binPath: c.GPTScriptBin, + requestPath: "fmt", + state: Creating, + toolPath: "", + content: string(b), + }, + } + + if run.url != "" { + err = run.request(ctx, Document{Nodes: nodes}) + } else { + err = run.exec(ctx, "fmt") + } + if err != nil { + return "", err + } + + out, err := run.Text() + if err != nil { + return "", err + } + if run.err != nil { + return run.ErrorOutput(), run.err + } + + return out, nil +} + +// Version will return the output of `gptscript --version` +func (c *Client) Version(ctx context.Context) (string, error) { + out, err := c.runBasicCommand(ctx, "--version", "version", "", "") + if err != nil { + return "", err + } + + return out, nil +} + +// ListTools will list all the available tools. +func (c *Client) ListTools(ctx context.Context) (string, error) { + out, err := c.runBasicCommand(ctx, "--list-tools", "list-tools", "", "") + if err != nil { + return "", err + } + + return out, nil +} + +// ListModels will list all the available models. +func (c *Client) ListModels(ctx context.Context) ([]string, error) { + out, err := c.runBasicCommand(ctx, "--list-models", "list-models", "", "") + if err != nil { + return nil, err + } + + return strings.Split(strings.TrimSpace(out), "\n"), nil +} + +func (c *Client) runBasicCommand(ctx context.Context, command, requestPath, toolPath, content string) (string, error) { + run := &runSubCommand{ + Run: Run{ + url: c.GPTScriptURL, + binPath: c.GPTScriptBin, + requestPath: requestPath, + state: Creating, + toolPath: toolPath, + content: content, + }, + } + + var err error + if run.url != "" { + var m any + if content != "" || toolPath != "" { + m = map[string]any{"input": content, "file": toolPath} + } + err = run.request(ctx, m) + } else { + err = run.exec(ctx, command) + } + if err != nil { + return "", err + } + + out, err := run.Text() + if err != nil { + return "", err + } + if run.err != nil { + return run.ErrorOutput(), run.err + } + + return out, nil +} + +func getCommand() string { + if gptScriptBin := os.Getenv("GPTSCRIPT_BIN"); gptScriptBin != "" { + if len(os.Args) == 0 { + return gptScriptBin + } + return determineProperCommand(filepath.Dir(os.Args[0]), gptScriptBin) + } + + return "gptscript" +} + +// determineProperCommand is for testing purposes. Users should use getCommand instead. +func determineProperCommand(dir, bin string) string { + if !strings.HasPrefix(bin, relativeToBinaryPath) { + return bin + } + + bin = filepath.Join(dir, strings.TrimPrefix(bin, relativeToBinaryPath)) + if !filepath.IsAbs(bin) { + bin = "." + string(os.PathSeparator) + bin + } + + slog.Debug("Using gptscript binary: " + bin) + return bin +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..9be8f02 --- /dev/null +++ b/client_test.go @@ -0,0 +1,629 @@ +package gptscript + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" +) + +var client *Client + +func TestMain(m *testing.M) { + if os.Getenv("OPENAI_API_KEY") == "" && os.Getenv("GPTSCRIPT_URL") == "" { + panic("OPENAI_API_KEY or GPTSCRIPT_URL environment variable must be set") + } + + client = &Client{GPTScriptURL: os.Getenv("GPTSCRIPT_URL"), GPTScriptBin: os.Getenv("GPTSCRIPT_BIN")} + os.Exit(m.Run()) +} + +func TestVersion(t *testing.T) { + out, err := client.Version(context.Background()) + if err != nil { + t.Errorf("Error getting version: %v", err) + } + + if !strings.HasPrefix(out, "gptscript version") { + t.Errorf("Unexpected output: %s", out) + } +} + +func TestListTools(t *testing.T) { + tools, err := client.ListTools(context.Background()) + if err != nil { + t.Errorf("Error listing tools: %v", err) + } + + if len(tools) == 0 { + t.Error("No tools found") + } +} + +func TestListModels(t *testing.T) { + models, err := client.ListModels(context.Background()) + if err != nil { + t.Errorf("Error listing models: %v", err) + } + + if len(models) == 0 { + t.Error("No models found") + } +} + +func TestSimpleEvaluate(t *testing.T) { + tool := &ToolDef{Instructions: "What is the capital of the united states?"} + + run, err := client.Evaluate(context.Background(), Opts{}, tool) + if err != nil { + t.Errorf("Error executing tool: %v", err) + } + + out, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %v", err) + } + + if !strings.Contains(out, "Washington") { + t.Errorf("Unexpected output: %s", out) + } + + // This should be able to be called multiple times and produce the same answer. + out, err = run.Text() + if err != nil { + t.Errorf("Error reading output: %v", err) + } + + if !strings.Contains(out, "Washington") { + t.Errorf("Unexpected output: %s", out) + } +} + +func TestRunFileChdir(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Error getting current working directory: %v", err) + } + // By changing the directory here, we should be able to find the test.gpt file without `./test` (see TestStreamRunFile) + run, err := client.Run(context.Background(), "test.gpt", Opts{Chdir: wd + "/test"}) + if err != nil { + t.Errorf("Error executing tool: %v", err) + } + + out, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %v", err) + } + + if out == "" { + t.Error("No output from tool") + } +} + +func TestEvaluateComplexTool(t *testing.T) { + tool := &ToolDef{ + JSONResponse: 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" + }] +} +`, + } + + run, err := client.Evaluate(context.Background(), Opts{DisableCache: true}, tool) + if err != nil { + t.Errorf("Error executing tool: %v", err) + } + + out, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %v", err) + } + + if !strings.Contains(out, "\"artists\":") { + t.Errorf("Unexpected output: %s", out) + } +} + +func TestEvaluateWithToolList(t *testing.T) { + shebang := "#!/bin/bash" + if runtime.GOOS == "windows" { + shebang = "#!/usr/bin/env powershell.exe" + } + tools := []fmt.Stringer{ + &ToolDef{ + Tools: []string{"echo"}, + Instructions: "echo hello there", + }, + &ToolDef{ + Name: "echo", + Tools: []string{"sys.exec"}, + Description: "Echoes the input", + Args: map[string]string{ + "input": "The string input to echo", + }, + Instructions: shebang + "\n echo ${input}", + }, + } + + run, err := client.Evaluate(context.Background(), Opts{}, tools...) + if err != nil { + t.Errorf("Error executing tool: %v", err) + } + + out, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %v", err) + } + + if !strings.Contains(out, "hello there") { + t.Errorf("Unexpected output: %s", out) + } +} + +func TestEvaluateWithToolListAndSubTool(t *testing.T) { + shebang := "#!/bin/bash" + if runtime.GOOS == "windows" { + shebang = "#!/usr/bin/env powershell.exe" + } + tools := []fmt.Stringer{ + &ToolDef{ + Tools: []string{"echo"}, + Instructions: "echo hello there", + }, + &ToolDef{ + Name: "other", + Tools: []string{"echo"}, + Instructions: "echo hello somewhere else", + }, + &ToolDef{ + Name: "echo", + Tools: []string{"sys.exec"}, + Description: "Echoes the input", + Args: map[string]string{ + "input": "The string input to echo", + }, + Instructions: shebang + "\n echo ${input}", + }, + } + + run, err := client.Evaluate(context.Background(), Opts{SubTool: "other"}, tools...) + if err != nil { + t.Errorf("Error executing tool: %v", err) + } + + out, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %v", err) + } + + if !strings.Contains(out, "hello somewhere else") { + t.Errorf("Unexpected output: %s", out) + } +} + +func TestStreamEvaluate(t *testing.T) { + var eventContent string + tool := &ToolDef{Instructions: "What is the capital of the united states?"} + + run, err := client.Evaluate(context.Background(), Opts{IncludeEvents: true}, tool) + if err != nil { + t.Errorf("Error executing tool: %v", err) + } + + for e := range run.Events() { + eventContent += e.Content + } + + out, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %v", err) + } + + if !strings.Contains(eventContent, "Washington") { + t.Errorf("Unexpected event output: %s", eventContent) + } + + if !strings.Contains(out, "Washington") { + t.Errorf("Unexpected output: %s", out) + } + + if len(run.ErrorOutput()) == 0 { + t.Error("No stderr output") + } +} + +func TestStreamRun(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Error getting working directory: %v", err) + } + + var eventContent string + run, err := client.Run(context.Background(), wd+"/test/catcher.gpt", Opts{IncludeEvents: true}) + if err != nil { + t.Errorf("Error executing file: %v", err) + } + + for e := range run.Events() { + eventContent += e.Content + } + + stdErr, err := io.ReadAll(run.stderr) + if err != nil { + t.Errorf("Error reading stderr: %v", err) + } + + out, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %v", err) + } + + if !strings.Contains(eventContent, "Salinger") { + t.Errorf("Unexpected event output: %s", eventContent) + } + + if !strings.Contains(out, "Salinger") { + t.Errorf("Unexpected output: %s", out) + } + + if len(stdErr) == 0 { + t.Error("No stderr output") + } +} + +func TestParseSimpleFile(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Error getting working directory: %v", err) + } + + tools, err := client.Parse(context.Background(), wd+"/test/test.gpt") + if err != nil { + t.Errorf("Error parsing file: %v", err) + } + + if len(tools) != 1 { + t.Fatalf("Unexpected number of tools: %d", len(tools)) + } + + if tools[0].ToolNode == nil { + t.Fatalf("No tool node found") + } + + if tools[0].ToolNode.Tool.Instructions != "Respond with a hello, in a random language. Also include the language in the response." { + t.Errorf("Unexpected instructions: %s", tools[0].ToolNode.Tool.Instructions) + } +} + +func TestParseTool(t *testing.T) { + tools, err := client.ParseTool(context.Background(), "echo hello") + if err != nil { + t.Errorf("Error parsing tool: %v", err) + } + + if len(tools) != 1 { + t.Fatalf("Unexpected number of tools: %d", len(tools)) + } + + if tools[0].ToolNode == nil { + t.Fatalf("No tool node found") + } + + if tools[0].ToolNode.Tool.Instructions != "echo hello" { + t.Errorf("Unexpected instructions: %s", tools[0].ToolNode.Tool.Instructions) + } +} + +func TestParseToolWithTextNode(t *testing.T) { + tools, err := client.ParseTool(context.Background(), "echo hello\n---\n!markdown\nhello") + if err != nil { + t.Errorf("Error parsing tool: %v", err) + } + + if len(tools) != 2 { + t.Fatalf("Unexpected number of tools: %d", len(tools)) + } + + if tools[0].ToolNode == nil { + t.Fatalf("No tool node found") + } + + if tools[0].ToolNode.Tool.Instructions != "echo hello" { + t.Errorf("Unexpected instructions: %s", tools[0].ToolNode.Tool.Instructions) + } + + if tools[1].TextNode == nil { + t.Fatalf("No text node found") + } + + if tools[1].TextNode.Text != "!markdown\nhello\n" { + t.Errorf("Unexpected text: %s", tools[1].TextNode.Text) + } +} + +func TestFmt(t *testing.T) { + nodes := []Node{ + { + ToolNode: &ToolNode{ + Tool: Tool{ + ToolDef: ToolDef{ + Tools: []string{"echo"}, + Instructions: "echo hello there", + }, + }, + }, + }, + { + ToolNode: &ToolNode{ + Tool: Tool{ + ToolDef: ToolDef{ + Name: "echo", + Instructions: "#!/bin/bash\necho hello there", + }, + Arguments: &openapi3.Schema{ + Type: "object", + Properties: map[string]*openapi3.SchemaRef{ + "input": { + Value: &openapi3.Schema{ + Description: "The string input to echo", + Type: "string", + }, + }, + }, + }, + }, + }, + }, + } + + out, err := client.Fmt(context.Background(), nodes) + if err != nil { + t.Errorf("Error formatting nodes: %v", err) + } + + if out != `Tools: echo + +echo hello there + +--- +Name: echo +Args: input: The string input to echo + +#!/bin/bash +echo hello there +` { + t.Errorf("Unexpected output: %s", out) + } +} + +func TestFmtWithTextNode(t *testing.T) { + nodes := []Node{ + { + ToolNode: &ToolNode{ + Tool: Tool{ + ToolDef: ToolDef{ + Tools: []string{"echo"}, + Instructions: "echo hello there", + }, + }, + }, + }, + { + TextNode: &TextNode{ + Text: "!markdown\nWe now echo hello there\n", + }, + }, + { + ToolNode: &ToolNode{ + Tool: Tool{ + ToolDef: ToolDef{ + Instructions: "#!/bin/bash\necho hello there", + Name: "echo", + }, + Arguments: &openapi3.Schema{ + Type: "object", + Properties: map[string]*openapi3.SchemaRef{ + "input": { + Value: &openapi3.Schema{ + Description: "The string input to echo", + Type: "string", + }, + }, + }, + }, + }, + }, + }, + } + + out, err := client.Fmt(context.Background(), nodes) + if err != nil { + t.Errorf("Error formatting nodes: %v", err) + } + + if out != `Tools: echo + +echo hello there + +--- +!markdown +We now echo hello there +--- +Name: echo +Args: input: The string input to echo + +#!/bin/bash +echo hello there +` { + t.Errorf("Unexpected output: %s", out) + } +} + +func TestToolChat(t *testing.T) { + tool := &ToolDef{ + Chat: true, + Instructions: "You are a chat bot. Don't finish the conversation until I say 'bye'.", + Tools: []string{"sys.chat.finish"}, + } + + run, err := client.Evaluate(context.Background(), Opts{DisableCache: true}, tool) + if err != nil { + t.Fatalf("Error executing tool: %v", err) + } + inputs := []string{ + "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?", + } + + expectedOutputs := []string{ + "California", + "Sacramento", + "Alaska Time Zone", + } + + // Just wait for the chat to start up. + _, err = run.Text() + if err != nil { + t.Fatalf("Error waiting for initial output: %v", err) + } + + for i, input := range inputs { + run, err = run.NextChat(context.Background(), input) + if err != nil { + t.Fatalf("Error sending next input %q: %v", input, err) + } + + out, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %s", run.ErrorOutput()) + t.Fatalf("Error reading output: %v", err) + } + + if !strings.Contains(out, expectedOutputs[i]) { + t.Fatalf("Unexpected output: %s", out) + } + } +} + +func TestFileChat(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Error getting current working directory: %v", err) + } + + run, err := client.Run(context.Background(), wd+"/test/chat.gpt", Opts{}) + if err != nil { + t.Fatalf("Error executing tool: %v", err) + } + inputs := []string{ + "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?", + } + + expectedOutputs := []string{ + "Lake Superior", + "Lake Michigan", + "Lake Huron", + } + + // Just wait for the chat to start up. + _, err = run.Text() + if err != nil { + t.Fatalf("Error waiting for initial output: %v", err) + } + + for i, input := range inputs { + run, err = run.NextChat(context.Background(), input) + if err != nil { + t.Fatalf("Error sending next input %q: %v", input, err) + } + + out, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %s", run.ErrorOutput()) + t.Fatalf("Error reading output: %v", err) + } + + if !strings.Contains(out, expectedOutputs[i]) { + t.Fatalf("Unexpected output: %s", out) + } + } +} + +func TestWorkspace(t *testing.T) { + tool := &ToolDef{ + Tools: []string{"sys.workspace.ls", "sys.workspace.write"}, + Instructions: "Write a file named 'hello.txt' in the workspace with contents 'Hello!' then list the files in the workspace.", + } + run, err := client.Evaluate(context.Background(), Opts{Workspace: "./workspace"}, tool) + if err != nil { + t.Errorf("Error getting workspace: %v", err) + } + + output, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %v", err) + } + + if !strings.Contains(output, "hello.txt") { + t.Errorf("Unexpected output from listing workspace: %s", output) + } +} + +func TestGetCommand(t *testing.T) { + currentEnvVar := os.Getenv("GPTSCRIPT_BIN") + t.Cleanup(func() { + _ = os.Setenv("GPTSCRIPT_BIN", currentEnvVar) + }) + + tests := []struct { + name string + envVar string + want string + }{ + { + name: "no env var set", + want: "gptscript", + }, + { + name: "env var set to absolute path", + envVar: "/usr/local/bin/gptscript", + want: "/usr/local/bin/gptscript", + }, + { + name: "env var set to relative path", + envVar: "../bin/gptscript", + want: "../bin/gptscript", + }, + { + name: "env var set to relative 'to me' path", + envVar: "/../bin/gptscript", + want: filepath.Join(filepath.Dir(os.Args[0]), "../bin/gptscript"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _ = os.Setenv("GPTSCRIPT_BIN", tt.envVar) + if got := getCommand(); got != tt.want { + t.Errorf("getCommand() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/event.go b/event.go new file mode 100644 index 0000000..59ecbf7 --- /dev/null +++ b/event.go @@ -0,0 +1,67 @@ +package gptscript + +import "time" + +type Event struct { + RunID string `json:"runID,omitempty"` + Time time.Time `json:"time,omitempty"` + CallContext *CallContext `json:"callContext,omitempty"` + ToolSubCalls map[string]Call `json:"toolSubCalls,omitempty"` + ToolResults int `json:"toolResults,omitempty"` + Type EventType `json:"type,omitempty"` + ChatCompletionID string `json:"chatCompletionId,omitempty"` + ChatRequest any `json:"chatRequest,omitempty"` + ChatResponse any `json:"chatResponse,omitempty"` + ChatResponseCached bool `json:"chatResponseCached,omitempty"` + Content string `json:"content,omitempty"` + Program *Program `json:"program,omitempty"` + Input string `json:"input,omitempty"` + Output string `json:"output,omitempty"` + Err string `json:"err,omitempty"` +} + +type Program struct { + Name string `json:"name,omitempty"` + EntryToolID string `json:"entryToolId,omitempty"` + ToolSet ToolSet `json:"toolSet,omitempty"` +} + +type ToolSet map[string]Tool + +type Call struct { + ToolID string `json:"toolID,omitempty"` + Input string `json:"input,omitempty"` +} + +type CallContext struct { + ID string `json:"id"` + Tool Tool `json:"tool"` + InputContext []InputContext `json:"inputContext"` + ToolCategory ToolCategory `json:"toolCategory,omitempty"` + ToolName string `json:"toolName,omitempty"` + ParentID string `json:"parentID,omitempty"` +} + +type InputContext struct { + ToolID string `json:"toolID,omitempty"` + Content string `json:"content,omitempty"` +} + +type ToolCategory string + +const ( + CredentialToolCategory ToolCategory = "credential" + ContextToolCategory ToolCategory = "context" + NoCategory ToolCategory = "" +) + +type EventType string + +const ( + EventTypeCallStart EventType = "callStart" + EventTypeCallContinue EventType = "callContinue" + EventTypeCallSubCalls EventType = "callSubCalls" + EventTypeCallProgress EventType = "callProgress" + EventTypeChat EventType = "callChat" + EventTypeCallFinish EventType = "callFinish" +) diff --git a/exec.go b/exec.go deleted file mode 100644 index a4f2ffe..0000000 --- a/exec.go +++ /dev/null @@ -1,361 +0,0 @@ -package gptscript - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" - "os/exec" - "path/filepath" - "strings" -) - -const relativeToBinaryPath = "" - -// Opts represents options for the gptscript tool or file. -type Opts struct { - DisableCache bool `json:"disableCache"` - CacheDir string `json:"cacheDir"` - Quiet bool `json:"quiet"` - Chdir string `json:"chdir"` - SubTool string `json:"subTool"` - Workspace string `json:"workspace"` -} - -func (o Opts) toArgs() []string { - var args []string - if o.DisableCache { - args = append(args, "--disable-cache") - } - if o.CacheDir != "" { - args = append(args, "--cache-dir="+o.CacheDir) - } - if o.Chdir != "" { - args = append(args, "--chdir="+o.Chdir) - } - if o.SubTool != "" { - args = append(args, "--sub-tool="+o.SubTool) - } - if o.Workspace != "" { - args = append(args, "--workspace="+o.Workspace) - } - return append(args, "--quiet="+fmt.Sprint(o.Quiet)) -} - -// Version will return the output of `gptscript --version` -func Version(ctx context.Context) (string, error) { - out, err := exec.CommandContext(ctx, getCommand(), "--version").CombinedOutput() - return string(bytes.TrimSpace(out)), err -} - -// ListTools will list all the available tools. -func ListTools(ctx context.Context) (string, error) { - out, err := exec.CommandContext(ctx, getCommand(), "--list-tools").CombinedOutput() - return string(out), err -} - -// ListModels will list all the available models. -func ListModels(ctx context.Context) ([]string, error) { - out, err := exec.CommandContext(ctx, getCommand(), "--list-models").CombinedOutput() - if err != nil { - return nil, err - } - return strings.Split(strings.TrimSpace(string(out)), "\n"), nil -} - -// ExecTool will execute a tool. The tool must be a fmt.Stringer, and the string should be a valid gptscript file. -func ExecTool(ctx context.Context, opts Opts, tools ...fmt.Stringer) (string, error) { - c := exec.CommandContext(ctx, getCommand(), append(opts.toArgs(), "-")...) - c.Stdin = strings.NewReader(concatTools(tools)) - - stdout, err := c.StdoutPipe() - if err != nil { - return "", fmt.Errorf("failed to get stdout pipe: %w", err) - } - - stderr, err := c.StderrPipe() - if err != nil { - return "", fmt.Errorf("failed to get stderr pipe: %w", err) - } - - if err = c.Start(); err != nil { - return "", fmt.Errorf("failed to start command: %w", err) - } - - stdErr, err := io.ReadAll(stderr) - if err != nil { - return "", fmt.Errorf("failed to read stderr: %w", err) - } - - stdOut, err := io.ReadAll(stdout) - if err != nil { - return "", fmt.Errorf("failed to read stdout: %w", err) - } - - if err = c.Wait(); err != nil { - return "", fmt.Errorf("failed to wait for command, stderr: %s: %w", stdErr, err) - } - - return string(stdOut), err -} - -// StreamExecTool will execute a tool. The tool must be a fmt.Stringer, and the string should be a valid gptscript file. -// This returns two io.ReadClosers, one for stdout and one for stderr, and a function to wait for the process to exit. -// Reading from stdOut and stdErr should be completed before calling the wait function. -func StreamExecTool(ctx context.Context, opts Opts, tools ...fmt.Stringer) (io.Reader, io.Reader, func() error) { - c, stdout, stderr, err := setupForkCommand(ctx, "", append(opts.toArgs(), "-")) - if err != nil { - return stdout, stderr, func() error { return err } - } - - c.Stdin = strings.NewReader(concatTools(tools)) - - if err = c.Start(); err != nil { - return stdout, stderr, func() error { return err } - } - - return stdout, stderr, c.Wait -} - -// StreamExecToolWithEvents will execute a tool. The tool must be a fmt.Stringer, and the string should be a valid gptscript file. -// This returns three io.ReadClosers, one for stdout, one for stderr, and one for events, and a function to wait for the process to exit. -// Reading from stdOut, stdErr, and events should be completed before calling the wait function. -func StreamExecToolWithEvents(ctx context.Context, opts Opts, tools ...fmt.Stringer) (io.Reader, io.Reader, io.Reader, func() error) { - eventsRead, eventsWrite, err := os.Pipe() - if err != nil { - return new(reader), new(reader), new(reader), func() error { return err } - } - // Close the parent pipe after starting the child process - defer eventsWrite.Close() - - c, stdout, stderr, err := setupForkCommand(ctx, "", append(opts.toArgs(), "-")) - if err != nil { - _ = eventsRead.Close() - return stdout, stderr, new(reader), func() error { return err } - } - - c.Stdin = strings.NewReader(concatTools(tools)) - - appendExtraFiles(c, eventsWrite) - - if err = c.Start(); err != nil { - _ = eventsRead.Close() - return stdout, stderr, new(reader), func() error { return err } - } - - wait := func() error { - err := c.Wait() - _ = eventsRead.Close() - return err - } - return stdout, stderr, eventsRead, wait -} - -// ExecFile will execute the file at the given path with the given input. -// The file at the path should be a valid gptscript file. -// The input should be command line arguments in the form of a string (i.e. "--arg1 value1 --arg2 value2"). -func ExecFile(ctx context.Context, toolPath, input string, opts Opts) (string, error) { - args := append(opts.toArgs(), toolPath) - if input != "" { - args = append(args, input) - } - - c := exec.CommandContext(ctx, getCommand(), args...) - - stdout, err := c.StdoutPipe() - if err != nil { - return "", fmt.Errorf("failed to get stdout pipe: %w", err) - } - - stderr, err := c.StderrPipe() - if err != nil { - return "", fmt.Errorf("failed to get stderr pipe: %w", err) - } - - if err = c.Start(); err != nil { - return "", fmt.Errorf("failed to start command: %w", err) - } - - stdErr, err := io.ReadAll(stderr) - if err != nil { - return "", fmt.Errorf("failed to read stderr: %w", err) - } - - stdOut, err := io.ReadAll(stdout) - if err != nil { - return "", fmt.Errorf("failed to read stdout: %w", err) - } - - if err = c.Wait(); err != nil { - return "", fmt.Errorf("failed to wait for command, stderr: %s: %w", stdErr, err) - } - - return string(stdOut), err -} - -// StreamExecFile will execute the file at the given path with the given input. -// The file at the path should be a valid gptscript file. -// The input should be command line arguments in the form of a string (i.e. "--arg1 value1 --arg2 value2"). -// This returns two io.ReadClosers, one for stdout and one for stderr, and a function to wait for the process to exit. -// Reading from stdOut and stdErr should be completed before calling the wait function. -func StreamExecFile(ctx context.Context, toolPath, input string, opts Opts) (io.Reader, io.Reader, func() error) { - args := append(opts.toArgs(), toolPath) - c, stdout, stderr, err := setupForkCommand(ctx, input, args) - if err != nil { - return stdout, stderr, func() error { return err } - } - - if err = c.Start(); err != nil { - return stdout, stderr, func() error { return err } - } - - return stdout, stderr, c.Wait -} - -// StreamExecFileWithEvents will execute the file at the given path with the given input. -// The file at the path should be a valid gptscript file. -// The input should be command line arguments in the form of a string (i.e. "--arg1 value1 --arg2 value2"). -// This returns three io.ReadClosers, one for stdout, one for stderr, and one for events, and a function to wait for the process to exit. -// Reading from stdOut, stdErr, and events should be completed before calling the wait function. -func StreamExecFileWithEvents(ctx context.Context, toolPath, input string, opts Opts) (io.Reader, io.Reader, io.Reader, func() error) { - eventsRead, eventsWrite, err := os.Pipe() - if err != nil { - return new(reader), new(reader), new(reader), func() error { return err } - } - // Close the parent pipe after starting the child process - defer eventsWrite.Close() - - args := append(opts.toArgs(), toolPath) - - c, stdout, stderr, err := setupForkCommand(ctx, input, args) - if err != nil { - _ = eventsRead.Close() - return stdout, stderr, new(reader), func() error { return err } - } - - appendExtraFiles(c, eventsWrite) - - if err = c.Start(); err != nil { - _ = eventsRead.Close() - return stdout, stderr, new(reader), func() error { return err } - } - - wait := func() error { - err := c.Wait() - _ = eventsRead.Close() - return err - } - - return stdout, stderr, eventsRead, wait -} - -// Parse will parse the given file into an array of Nodes. -func Parse(ctx context.Context, fileName string, opts Opts) ([]Node, error) { - output, err := exec.CommandContext(ctx, getCommand(), append(opts.toArgs(), "parse", fileName)...).CombinedOutput() - if err != nil { - return nil, err - } - - var doc Document - if err = json.Unmarshal(output, &doc); err != nil { - return nil, err - } - - return doc.Nodes, nil -} - -// ParseTool will parse the given string into a tool. -func ParseTool(ctx context.Context, input string) ([]Node, error) { - c := exec.CommandContext(ctx, getCommand(), "parse", "-") - c.Stdin = strings.NewReader(input) - - output, err := c.CombinedOutput() - if err != nil { - return nil, err - } - - var doc Document - if err = json.Unmarshal(output, &doc); err != nil { - return nil, err - } - - return doc.Nodes, nil -} - -// Fmt will format the given nodes into a string. -func Fmt(ctx context.Context, nodes []Node) (string, error) { - b, err := json.Marshal(Document{Nodes: nodes}) - if err != nil { - return "", fmt.Errorf("failed to marshal nodes: %w", err) - } - - c := exec.CommandContext(ctx, getCommand(), "fmt", "-") - c.Stdin = bytes.NewReader(b) - - output, err := c.CombinedOutput() - if err != nil { - return "", err - } - - return string(output), nil -} - -func concatTools(tools []fmt.Stringer) string { - var sb strings.Builder - for i, tool := range tools { - sb.WriteString(tool.String()) - if i < len(tools)-1 { - sb.WriteString("\n---\n") - } - } - return sb.String() -} - -func getCommand() string { - if gptScriptBin := os.Getenv("GPTSCRIPT_BIN"); gptScriptBin != "" { - if len(os.Args) == 0 { - return gptScriptBin - } - return determineProperCommand(filepath.Dir(os.Args[0]), gptScriptBin) - } - - return "gptscript" -} - -// determineProperCommand is for testing purposes. Users should use getCommand instead. -func determineProperCommand(dir, bin string) string { - if !strings.HasPrefix(bin, relativeToBinaryPath) { - return bin - } - - bin = filepath.Join(dir, strings.TrimPrefix(bin, relativeToBinaryPath)) - if !filepath.IsAbs(bin) { - bin = "." + string(os.PathSeparator) + bin - } - - slog.Debug("Using gptscript binary: " + bin) - return bin -} - -func setupForkCommand(ctx context.Context, input string, args []string) (*exec.Cmd, io.Reader, io.Reader, error) { - if input != "" { - args = append(args, input) - } - - c := exec.CommandContext(ctx, getCommand(), args...) - - stdout, err := c.StdoutPipe() - if err != nil { - return nil, new(reader), new(reader), err - } - - stderr, err := c.StderrPipe() - if err != nil { - return nil, stdout, new(reader), err - } - - return c, stdout, stderr, nil -} diff --git a/exec_test.go b/exec_test.go deleted file mode 100644 index a6464cc..0000000 --- a/exec_test.go +++ /dev/null @@ -1,576 +0,0 @@ -package gptscript - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/getkin/kin-openapi/openapi3" -) - -func TestMain(m *testing.M) { - if os.Getenv("OPENAI_API_KEY") == "" { - panic("OPENAI_API_KEY not set") - } - - os.Exit(m.Run()) -} - -func TestVersion(t *testing.T) { - out, err := Version(context.Background()) - if err != nil { - t.Errorf("Error getting version: %v", err) - } - - if !strings.HasPrefix(out, "gptscript version") { - t.Errorf("Unexpected output: %s", out) - } -} - -func TestListTools(t *testing.T) { - tools, err := ListTools(context.Background()) - if err != nil { - t.Errorf("Error listing tools: %v", err) - } - - if len(tools) == 0 { - t.Error("No tools found") - } -} - -func TestListModels(t *testing.T) { - models, err := ListModels(context.Background()) - if err != nil { - t.Errorf("Error listing models: %v", err) - } - - if len(models) == 0 { - t.Error("No models found") - } -} - -func TestSimpleExec(t *testing.T) { - tool := &FreeForm{Content: "What is the capital of the united states?"} - - out, err := ExecTool(context.Background(), Opts{}, tool) - if err != nil { - t.Errorf("Error executing tool: %v", err) - } - - if !strings.Contains(out, "Washington") { - t.Errorf("Unexpected output: %s", out) - } -} - -func TestExecFileChdir(t *testing.T) { - // By changing the directory here, we should be able to find the test.gpt file without `./test` (see TestStreamExecFile) - out, err := ExecFile(context.Background(), "test.gpt", "", Opts{Chdir: "./test"}) - if err != nil { - t.Errorf("Error executing tool: %v", err) - } - - if out == "" { - t.Error("No output from tool") - } -} - -func TestExecComplexTool(t *testing.T) { - tool := &SimpleTool{ - JSONResponse: 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" - }] -} -`, - } - - out, err := ExecTool(context.Background(), Opts{}, tool) - if err != nil { - t.Errorf("Error executing tool: %v", err) - } - - if !strings.Contains(out, "\"artists\":") { - t.Errorf("Unexpected output: %s", out) - } -} - -func TestExecWithToolList(t *testing.T) { - shebang := "#!/bin/bash" - if runtime.GOOS == "windows" { - shebang = "#!/usr/bin/env powershell.exe" - } - tools := []fmt.Stringer{ - &SimpleTool{ - Tools: []string{"echo"}, - Instructions: "echo hello there", - }, - &SimpleTool{ - Name: "echo", - Tools: []string{"sys.exec"}, - Description: "Echoes the input", - Args: map[string]string{ - "input": "The string input to echo", - }, - Instructions: shebang + "\n echo ${input}", - }, - } - - out, err := ExecTool(context.Background(), Opts{}, tools...) - if err != nil { - t.Errorf("Error executing tool: %v", err) - } - - if !strings.Contains(out, "hello there") { - t.Errorf("Unexpected output: %s", out) - } -} - -func TestExecWithToolListAndSubTool(t *testing.T) { - shebang := "#!/bin/bash" - if runtime.GOOS == "windows" { - shebang = "#!/usr/bin/env powershell.exe" - } - tools := []fmt.Stringer{ - &SimpleTool{ - Tools: []string{"echo"}, - Instructions: "echo hello there", - }, - &SimpleTool{ - Name: "other", - Tools: []string{"echo"}, - Instructions: "echo hello somewhere else", - }, - &SimpleTool{ - Name: "echo", - Tools: []string{"sys.exec"}, - Description: "Echoes the input", - Args: map[string]string{ - "input": "The string input to echo", - }, - Instructions: shebang + "\n echo ${input}", - }, - } - - out, err := ExecTool(context.Background(), Opts{SubTool: "other"}, tools...) - if err != nil { - t.Errorf("Error executing tool: %v", err) - } - - if !strings.Contains(out, "hello somewhere else") { - t.Errorf("Unexpected output: %s", out) - } -} - -func TestStreamExec(t *testing.T) { - tool := &FreeForm{Content: "What is the capital of the united states?"} - - stdout, stderr, wait := StreamExecTool(context.Background(), Opts{}, tool) - - stdOut, err := io.ReadAll(stdout) - if err != nil { - t.Errorf("Error reading stdout: %v", err) - } - - stdErr, err := io.ReadAll(stderr) - if err != nil { - t.Errorf("Error reading stderr: %v", err) - } - - if err = wait(); err != nil { - t.Errorf("Error waiting for process: %v", err) - } - - if !strings.Contains(string(stdOut), "Washington") { - t.Errorf("Unexpected output: %s", string(stdOut)) - } - - if len(stdErr) == 0 { - t.Error("No stderr output") - } -} - -func TestStreamExecFile(t *testing.T) { - stdout, stderr, wait := StreamExecFile(context.Background(), "./test/test.gpt", "", Opts{}) - - stdOut, err := io.ReadAll(stdout) - if err != nil { - t.Errorf("Error reading stdout: %v", err) - } - - stdErr, err := io.ReadAll(stderr) - if err != nil { - t.Errorf("Error reading stderr: %v", err) - } - - if err = wait(); err != nil { - t.Errorf("Error waiting for process: %v", err) - } - - if len(stdOut) == 0 { - t.Error("No stdout output") - } - if len(stdErr) == 0 { - t.Error("No stderr output") - } -} - -func TestStreamExecToolWithEvents(t *testing.T) { - tool := &FreeForm{Content: "What is the capital of the united states?"} - - stdout, stderr, events, wait := StreamExecToolWithEvents(context.Background(), Opts{}, tool) - - stdOut, err := io.ReadAll(stdout) - if err != nil { - t.Errorf("Error reading stdout: %v", err) - } - - stdErr, err := io.ReadAll(stderr) - if err != nil { - t.Errorf("Error reading stderr: %v", err) - } - - eventsOut, err := io.ReadAll(events) - if err != nil { - t.Errorf("Error reading events: %v", err) - } - - if err = wait(); err != nil { - t.Errorf("Error waiting for process: %v", err) - } - - if !strings.Contains(string(stdOut), "Washington") { - t.Errorf("Unexpected output: %s", string(stdOut)) - } - - if len(stdErr) == 0 { - t.Error("No stderr output") - } - - if len(eventsOut) == 0 { - t.Error("No events output") - } -} - -func TestStreamExecFileWithEvents(t *testing.T) { - stdout, stderr, events, wait := StreamExecFileWithEvents(context.Background(), "./test/test.gpt", "", Opts{}) - - stdOut, err := io.ReadAll(stdout) - if err != nil { - t.Errorf("Error reading stdout: %v", err) - } - - stdErr, err := io.ReadAll(stderr) - if err != nil { - t.Errorf("Error reading stderr: %v", err) - } - - eventsOut, err := io.ReadAll(events) - if err != nil { - t.Errorf("Error reading events: %v", err) - } - - if err = wait(); err != nil { - fmt.Println(string(stdErr)) - t.Errorf("Error waiting for process: %v", err) - } - - if len(stdOut) == 0 { - t.Error("No stdout output") - } - - if len(stdErr) == 0 { - t.Error("No stderr output") - } - - if len(eventsOut) == 0 { - t.Error("No events output") - } -} - -func TestParseSimpleFile(t *testing.T) { - tools, err := Parse(context.Background(), "./test/test.gpt", Opts{}) - if err != nil { - t.Errorf("Error parsing file: %v", err) - } - - if len(tools) != 1 { - t.Errorf("Unexpected number of tools: %d", len(tools)) - } - - if tools[0].ToolNode == nil { - t.Error("No tool node found") - } - - if tools[0].ToolNode.Tool.Instructions != "Respond with a hello, in a random language. Also include the language in the response." { - t.Errorf("Unexpected instructions: %s", tools[0].ToolNode.Tool.Instructions) - } -} - -func TestParseSimpleFileWithChdir(t *testing.T) { - tools, err := Parse(context.Background(), "./test.gpt", Opts{Chdir: "./test"}) - if err != nil { - t.Errorf("Error parsing file: %v", err) - } - - if len(tools) != 1 { - t.Errorf("Unexpected number of tools: %d", len(tools)) - } - - if tools[0].ToolNode == nil { - t.Error("No tool node found") - } - - if tools[0].ToolNode.Tool.Instructions != "Respond with a hello, in a random language. Also include the language in the response." { - t.Errorf("Unexpected instructions: %s", tools[0].ToolNode.Tool.Instructions) - } -} - -func TestParseTool(t *testing.T) { - tools, err := ParseTool(context.Background(), "echo hello") - if err != nil { - t.Errorf("Error parsing tool: %v", err) - } - - if len(tools) != 1 { - t.Errorf("Unexpected number of tools: %d", len(tools)) - } - - if tools[0].ToolNode == nil { - t.Error("No tool node found") - } - - if tools[0].ToolNode.Tool.Instructions != "echo hello" { - t.Errorf("Unexpected instructions: %s", tools[0].ToolNode.Tool.Instructions) - } -} - -func TestParseToolWithTextNode(t *testing.T) { - tools, err := ParseTool(context.Background(), "echo hello\n---\n!markdown\nhello") - if err != nil { - t.Errorf("Error parsing tool: %v", err) - } - - if len(tools) != 2 { - t.Errorf("Unexpected number of tools: %d", len(tools)) - } - - if tools[0].ToolNode == nil { - t.Error("No tool node found") - } - - if tools[0].ToolNode.Tool.Instructions != "echo hello" { - t.Errorf("Unexpected instructions: %s", tools[0].ToolNode.Tool.Instructions) - } - - if tools[1].TextNode == nil { - t.Error("No text node found") - } - - if tools[1].TextNode.Text != "!markdown\nhello\n" { - t.Errorf("Unexpected text: %s", tools[1].TextNode.Text) - } -} - -func TestFmt(t *testing.T) { - nodes := []Node{ - { - ToolNode: &ToolNode{ - Tool: Tool{ - Parameters: Parameters{ - Tools: []string{"echo"}, - }, - Instructions: "echo hello there", - }, - }, - }, - { - ToolNode: &ToolNode{ - Tool: Tool{ - Parameters: Parameters{ - Name: "echo", - Arguments: &openapi3.Schema{ - Type: "object", - Properties: map[string]*openapi3.SchemaRef{ - "input": { - Value: &openapi3.Schema{ - Description: "The string input to echo", - Type: "string", - }, - }, - }, - }, - }, - Instructions: "#!/bin/bash\necho hello there", - }, - }, - }, - } - - out, err := Fmt(context.Background(), nodes) - if err != nil { - t.Errorf("Error formatting nodes: %v", err) - } - - if out != `Tools: echo - -echo hello there - ---- -Name: echo -Args: input: The string input to echo - -#!/bin/bash -echo hello there -` { - t.Errorf("Unexpected output: %s", out) - } -} - -func TestFmtWithTextNode(t *testing.T) { - nodes := []Node{ - { - ToolNode: &ToolNode{ - Tool: Tool{ - Parameters: Parameters{ - Tools: []string{"echo"}, - }, - Instructions: "echo hello there", - }, - }, - }, - { - TextNode: &TextNode{ - Text: "!markdown\nWe now echo hello there\n", - }, - }, - { - ToolNode: &ToolNode{ - Tool: Tool{ - Parameters: Parameters{ - Name: "echo", - Arguments: &openapi3.Schema{ - Type: "object", - Properties: map[string]*openapi3.SchemaRef{ - "input": { - Value: &openapi3.Schema{ - Description: "The string input to echo", - Type: "string", - }, - }, - }, - }, - }, - Instructions: "#!/bin/bash\necho hello there", - }, - }, - }, - } - - out, err := Fmt(context.Background(), nodes) - if err != nil { - t.Errorf("Error formatting nodes: %v", err) - } - - if out != `Tools: echo - -echo hello there - ---- -!markdown -We now echo hello there ---- -Name: echo -Args: input: The string input to echo - -#!/bin/bash -echo hello there -` { - t.Errorf("Unexpected output: %s", out) - } -} - -func TestExecWithWorkspace(t *testing.T) { - tool := &SimpleTool{ - Tools: []string{"sys.workspace.ls", "sys.workspace.write"}, - Instructions: "Write a file named 'hello.txt' to the workspace with the content 'Hello!' and list the contents of the workspace.", - } - - out, err := ExecTool(context.Background(), Opts{Workspace: "./workspace"}, tool) - if err != nil { - t.Errorf("Error executing tool: %v", err) - } - - if !strings.Contains(out, "hello.txt") { - t.Errorf("Unexpected output: %s", out) - } -} - -func TestDetermineProperCommand(t *testing.T) { - tests := []struct { - name string - dir, bin string - want string - }{ - { - name: "no dir", - bin: "gptscript", - want: "gptscript", - }, - { - name: "bin set to absolute path", - bin: string(os.PathSeparator) + filepath.Join("usr", "local", "bin", "gptscript"), - dir: string(os.PathSeparator) + filepath.Join("usr", "local"), - want: string(os.PathSeparator) + filepath.Join("usr", "local", "bin", "gptscript"), - }, - { - name: "bin set to relative path", - bin: filepath.Join("..", "bin", "gptscript"), - dir: string(os.PathSeparator) + filepath.Join("usr", "local"), - want: filepath.Join("..", "bin", "gptscript"), - }, - { - name: "bin set to relative 'to me' path with os.Args[0]", - bin: "" + string(os.PathSeparator) + filepath.Join("..", "bin", "gptscript"), - dir: filepath.Dir(os.Args[0]), - want: filepath.Join(filepath.Dir(os.Args[0]), filepath.Join("..", "bin", "gptscript")), - }, - { - name: "env var set to relative 'to me' path with extra and dir is current", - bin: "" + string(os.PathSeparator) + filepath.Join("..", "bin", "gptscript"), - dir: ".", - want: "." + string(os.PathSeparator) + filepath.Join("..", "bin", "gptscript"), - }, - { - name: "env var set to relative 'to me' path and dir is current", - bin: "" + string(os.PathSeparator) + "gptscript", - dir: ".", - want: "." + string(os.PathSeparator) + "gptscript", - }, - { - name: "env var set to relative 'to me' path with extra and dir is current", - bin: "" + string(os.PathSeparator) + filepath.Join("..", "bin", "gptscript"), - dir: ".", - want: "." + string(os.PathSeparator) + filepath.Join("..", "bin", "gptscript"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := determineProperCommand(tt.dir, tt.bin); got != tt.want { - t.Errorf("determineProperCommand() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/exec_unix.go b/exec_unix.go index 6502048..5a987d2 100644 --- a/exec_unix.go +++ b/exec_unix.go @@ -10,5 +10,5 @@ import ( func appendExtraFiles(cmd *exec.Cmd, extraFiles ...*os.File) { cmd.ExtraFiles = append(cmd.ExtraFiles, extraFiles...) - cmd.Args = append(cmd.Args[:1], append([]string{"--events-stream-to", fmt.Sprintf("fd://%d", len(cmd.ExtraFiles)+2)}, cmd.Args[1:]...)...) + cmd.Args = append(cmd.Args[:1], append([]string{fmt.Sprintf("--events-stream-to=fd://%d", len(cmd.ExtraFiles)+2)}, cmd.Args[1:]...)...) } diff --git a/opts.go b/opts.go new file mode 100644 index 0000000..4b9fed2 --- /dev/null +++ b/opts.go @@ -0,0 +1,38 @@ +package gptscript + +import ( + "fmt" +) + +// Opts represents options for the gptscript tool or file. +type Opts struct { + Input string `json:"input"` + DisableCache bool `json:"disableCache"` + CacheDir string `json:"cacheDir"` + Quiet bool `json:"quiet"` + Chdir string `json:"chdir"` + SubTool string `json:"subTool"` + Workspace string `json:"workspace"` + ChatState string `json:"chatState"` + IncludeEvents bool `json:"includeEvents"` +} + +func (o Opts) toArgs() []string { + var args []string + if o.DisableCache { + args = append(args, "--disable-cache") + } + if o.CacheDir != "" { + args = append(args, "--cache-dir="+o.CacheDir) + } + if o.Chdir != "" { + args = append(args, "--chdir="+o.Chdir) + } + if o.SubTool != "" { + args = append(args, "--sub-tool="+o.SubTool) + } + if o.Workspace != "" { + args = append(args, "--workspace="+o.Workspace) + } + return append(args, "--quiet="+fmt.Sprint(o.Quiet)) +} diff --git a/run.go b/run.go new file mode 100644 index 0000000..07b9421 --- /dev/null +++ b/run.go @@ -0,0 +1,545 @@ +package gptscript + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + "sync" +) + +type Run struct { + url, binPath, requestPath, toolPath, content string + opts Opts + state RunState + chatState string + cmd *exec.Cmd + err error + stdout, stderr io.Reader + wait func() error + + rawOutput map[string]any + output, errput []byte + events chan Event + lock sync.Mutex + complete bool +} + +// Text returns the text output of the gptscript. It blocks until the output is ready. +func (r *Run) Text() (string, error) { + out, err := r.Bytes() + if err != nil { + return "", err + } + + return string(out), nil +} + +// Bytes returns the output of the gptscript in bytes. It blocks until the output is ready. +func (r *Run) Bytes() ([]byte, error) { + if err := r.readAllOutput(); err != nil { + return nil, err + } + if r.err != nil { + return nil, r.err + } + + return r.output, nil +} + +// State returns the current state of the gptscript. +func (r *Run) State() RunState { + return r.state +} + +// ErrorOutput returns the stderr output of the gptscript. +// Should only be called after Bytes or Text has returned an error. +func (r *Run) ErrorOutput() string { + return string(r.errput) +} + +// Events returns a channel that streams the gptscript events as they occur. +func (r *Run) Events() <-chan Event { + return r.events +} + +// Close will stop the gptscript run, if it is running. +func (r *Run) Close() error { + // If the command was not started, then report error. + if r.cmd == nil || r.cmd.Process == nil { + return fmt.Errorf("run not started") + } + + // If the command has already exited, then nothing to do. + if r.cmd.ProcessState == nil { + return nil + } + + if err := r.cmd.Process.Signal(os.Kill); err != nil { + return err + } + + return r.wait() +} + +// RawOutput returns the raw output of the gptscript. Most users should use Text or Bytes instead. +func (r *Run) RawOutput() (map[string]any, error) { + if _, err := r.Bytes(); err != nil { + return nil, err + } + return r.rawOutput, nil +} + +// NextChat will pass input and create the next run in a chat. +// The new Run will be returned. +func (r *Run) NextChat(ctx context.Context, input string) (*Run, error) { + if r.state != Creating && r.state != Continue { + return nil, fmt.Errorf("run must be in creating or continue state not %q", r.state) + } + + run := &Run{ + url: r.url, + binPath: r.binPath, + requestPath: r.requestPath, + state: Creating, + chatState: r.chatState, + toolPath: r.toolPath, + content: r.content, + opts: r.opts, + } + run.opts.Input = input + if run.chatState != "" { + run.opts.ChatState = run.chatState + } + + if run.url != "" { + var payload any + if r.content != "" { + payload = requestPayload{ + Content: run.content, + Input: input, + Opts: run.opts, + } + } else if run.toolPath != "" { + payload = requestPayload{ + File: run.toolPath, + Input: input, + Opts: run.opts, + } + } + + return run, run.request(ctx, payload) + } + + return run, run.exec(ctx) +} + +func (r *Run) exec(ctx context.Context, extraArgs ...string) error { + eventsRead, eventsWrite, err := os.Pipe() + if err != nil { + r.state = Error + r.err = fmt.Errorf("failed to create events reader: %w", err) + return r.err + } + + // Close the parent pipe after starting the child process + defer eventsWrite.Close() + + chatState := r.chatState + if chatState == "" { + chatState = "null" + } + args := append(r.opts.toArgs(), "--chat-state="+chatState) + args = append(args, extraArgs...) + if r.toolPath != "" { + args = append(args, r.toolPath) + } + + cancelCtx, cancel := context.WithCancel(ctx) + c, stdout, stderr, err := setupForkCommand(cancelCtx, r.binPath, r.content, r.opts.Input, args, eventsWrite) + if err != nil { + cancel() + _ = eventsRead.Close() + r.state = Error + r.err = fmt.Errorf("failed to setup gptscript: %w", err) + return r.err + } + + if err = c.Start(); err != nil { + cancel() + _ = eventsRead.Close() + r.state = Error + r.err = fmt.Errorf("failed to start gptscript: %w", err) + return r.err + } + + r.state = Running + r.cmd = c + r.stdout = stdout + r.stderr = stderr + r.events = make(chan Event, 100) + go r.readEvents(cancelCtx, eventsRead) + + r.wait = func() error { + err := c.Wait() + _ = eventsRead.Close() + cancel() + if err != nil { + r.state = Error + r.err = fmt.Errorf("failed to wait for gptscript: %w", err) + } else { + if r.state == Running { + r.state = Finished + } + } + return r.err + } + + return nil +} + +func (r *Run) readEvents(ctx context.Context, events io.Reader) { + defer close(r.events) + + scan := bufio.NewScanner(events) + for scan.Scan() { + if !r.opts.IncludeEvents { + continue + } + + line := scan.Bytes() + if len(line) == 0 { + continue + } + + var event Event + if err := json.Unmarshal(line, &event); err != nil { + slog.Debug("failed to unmarshal event", "error", err, "event", string(line)) + continue + } + + select { + case <-ctx.Done(): + go func() { + for scan.Scan() { + // Drain any remaining events + } + }() + return + case r.events <- event: + } + } +} + +func (r *Run) readAllOutput() error { + r.lock.Lock() + defer r.lock.Unlock() + if r.complete { + return nil + } + r.complete = true + + done := true + errChan := make(chan error) + go func() { + var err error + r.errput, err = io.ReadAll(r.stderr) + errChan <- err + }() + + go func() { + var err error + r.output, err = io.ReadAll(r.stdout) + errChan <- err + }() + + for range 2 { + err := <-errChan + if err != nil { + r.err = fmt.Errorf("failed to read output: %w", err) + } + } + + if isObject(r.output) { + var chatOutput map[string]any + if err := json.Unmarshal(r.output, &chatOutput); err != nil { + r.state = Error + r.err = fmt.Errorf("failed to parse chat output: %w", err) + } + + chatState, err := json.Marshal(chatOutput["state"]) + if err != nil { + r.state = Error + r.err = fmt.Errorf("failed to process chat state: %w", err) + } + r.chatState = string(chatState) + + if content, ok := chatOutput["content"].(string); ok { + r.output = []byte(content) + } + + done, _ = chatOutput["done"].(bool) + r.rawOutput = chatOutput + } else { + if unquoted, err := strconv.Unquote(string(r.output)); err == nil { + r.output = []byte(unquoted) + } + } + + if r.err != nil { + r.state = Error + } else if done { + r.state = Finished + } else { + r.state = Continue + } + + return r.wait() +} + +func (r *Run) request(ctx context.Context, payload any) (err error) { + var ( + req *http.Request + url = fmt.Sprintf("%s/%s", r.url, r.requestPath) + cancelCtx, cancel = context.WithCancelCause(ctx) + ) + + defer func() { + if err != nil { + cancel(err) + } + }() + + if payload == nil { + req, err = http.NewRequestWithContext(cancelCtx, http.MethodGet, url, nil) + } else { + var b []byte + b, err = json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err = http.NewRequestWithContext(cancelCtx, http.MethodPost, url, bytes.NewReader(b)) + } + if err != nil { + r.state = Error + r.err = fmt.Errorf("failed to create request: %w", err) + return r.err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + r.state = Error + r.err = fmt.Errorf("failed to make request: %w", err) + return r.err + } + + if resp.StatusCode != http.StatusOK { + r.state = Error + r.err = fmt.Errorf("unexpected response status: %s", resp.Status) + return r.err + } + + r.state = Running + + stdout, stdoutWriter := io.Pipe() + stderr, stderrWriter := io.Pipe() + eventsRead, eventsWrite := io.Pipe() + + r.stdout = stdout + r.stderr = stderr + + r.events = make(chan Event, 100) + go r.readEvents(cancelCtx, eventsRead) + + go func() { + bufferedStdout := bufio.NewWriter(stdoutWriter) + bufferedStderr := bufio.NewWriter(stderrWriter) + scan := bufio.NewScanner(resp.Body) + defer func() { + go func() { + for scan.Scan() { + // Drain any remaining events + } + }() + + eventsWrite.Close() + + bufferedStderr.Flush() + stderrWriter.Close() + + bufferedStdout.Flush() + stdoutWriter.Close() + + cancel(r.err) + + resp.Body.Close() + }() + + for scan.Scan() { + line := bytes.TrimSpace(bytes.TrimPrefix(scan.Bytes(), []byte("data: "))) + if len(line) == 0 { + continue + } + if bytes.Equal(line, []byte("[DONE]")) { + return + } + + if bytes.HasPrefix(line, []byte(`{"stdout":`)) { + _, err = bufferedStdout.Write(bytes.TrimSuffix(bytes.TrimPrefix(line, []byte(`{"stdout":`)), []byte("}"))) + if err != nil { + r.state = Error + r.err = fmt.Errorf("failed to write stdout: %w", err) + return + } + } else if bytes.HasPrefix(line, []byte(`{"stderr":`)) { + _, err = bufferedStderr.Write(bytes.TrimSuffix(bytes.TrimPrefix(line, []byte(`{"stderr":`)), []byte("}"))) + if err != nil { + r.state = Error + r.err = fmt.Errorf("failed to write stderr: %w", err) + return + } + } else { + _, err = eventsWrite.Write(append(line, '\n')) + if err != nil { + r.state = Error + r.err = fmt.Errorf("failed to write events: %w", err) + return + } + } + } + }() + + r.wait = func() error { + <-cancelCtx.Done() + stdout.Close() + stderr.Close() + eventsRead.Close() + if err := context.Cause(cancelCtx); !errors.Is(err, context.Canceled) && r.err == nil { + r.state = Error + r.err = err + } + return r.err + } + + return nil +} + +type RunState string + +const ( + Creating RunState = "creating" + Running RunState = "running" + Continue RunState = "continue" + Finished RunState = "finished" + Error RunState = "error" +) + +func setupForkCommand(ctx context.Context, bin, content, input string, args []string, extraFiles ...*os.File) (*exec.Cmd, io.Reader, io.Reader, error) { + var stdin io.Reader + if content != "" { + args = append(args, "-") + stdin = strings.NewReader(content) + } + + if input != "" { + args = append(args, input) + } + + c := exec.CommandContext(ctx, bin, args...) + if len(extraFiles) > 0 { + appendExtraFiles(c, extraFiles...) + } + + if content != "" { + c.Stdin = stdin + } + + stdout, err := c.StdoutPipe() + if err != nil { + return nil, new(reader), new(reader), err + } + + stderr, err := c.StderrPipe() + if err != nil { + return nil, stdout, new(reader), err + } + + return c, stdout, stderr, nil +} + +type runSubCommand struct { + Run +} + +func (r *runSubCommand) Bytes() ([]byte, error) { + r.lock.Lock() + defer r.lock.Unlock() + if r.complete { + return r.output, r.err + } + r.complete = true + + errChan := make(chan error) + go func() { + var err error + r.errput, err = io.ReadAll(r.stderr) + if unquoted, err := strconv.Unquote(string(r.errput)); err == nil { + r.errput = []byte(unquoted) + } + errChan <- err + }() + + go func() { + var err error + r.output, err = io.ReadAll(r.stdout) + if unquoted, err := strconv.Unquote(string(r.output)); err == nil { + r.output = []byte(unquoted) + } + errChan <- err + }() + + for range 2 { + err := <-errChan + if err != nil { + r.err = fmt.Errorf("failed to read output: %w", err) + } + } + + if r.err != nil { + r.state = Error + } else { + r.state = Finished + } + + if err := r.wait(); err != nil { + return nil, err + } + + return r.output, r.err +} + +func (r *runSubCommand) Text() (string, error) { + output, err := r.Bytes() + return string(output), err +} + +type requestPayload struct { + Content string `json:"content"` + File string `json:"file"` + Input string `json:"input"` + Opts `json:",inline"` +} + +func isObject(b []byte) bool { + return len(b) > 0 && b[0] == '{' +} diff --git a/test/catcher.gpt b/test/catcher.gpt new file mode 100644 index 0000000..a186c79 --- /dev/null +++ b/test/catcher.gpt @@ -0,0 +1 @@ +Who wrote Catcher in the Rye? \ No newline at end of file diff --git a/test/chat.gpt b/test/chat.gpt new file mode 100644 index 0000000..d47e244 --- /dev/null +++ b/test/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/tool.go b/tool.go index de4f21c..c4e0125 100644 --- a/tool.go +++ b/tool.go @@ -7,40 +7,31 @@ import ( "github.com/getkin/kin-openapi/openapi3" ) -// SimpleTool struct represents a tool with various configurations. -type SimpleTool struct { - Name string - Description string - Tools []string - MaxTokens *int // Using a pointer to represent optional int - Model string - Cache bool - Temperature *float64 // Using a pointer to represent optional float64 - Args map[string]string - InternalPrompt bool - Instructions string - JSONResponse bool +// ToolDef struct represents a tool with various configurations. +type ToolDef struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + MaxTokens int `json:"maxTokens,omitempty"` + ModelName string `json:"modelName,omitempty"` + ModelProvider bool `json:"modelProvider,omitempty"` + JSONResponse bool `json:"jsonResponse,omitempty"` + Chat bool `json:"chat,omitempty"` + Temperature *float32 `json:"temperature,omitempty"` + Cache *bool `json:"cache,omitempty"` + InternalPrompt *bool `json:"internalPrompt"` + Args map[string]string `json:"args,omitempty"` + Tools []string `json:"tools,omitempty"` + GlobalTools []string `json:"globalTools,omitempty"` + GlobalModelName string `json:"globalModelName,omitempty"` + Context []string `json:"context,omitempty"` + ExportContext []string `json:"exportContext,omitempty"` + Export []string `json:"export,omitempty"` + Credentials []string `json:"credentials,omitempty"` + Instructions string `json:"instructions,omitempty"` } -// NewSimpleTool is a constructor for SimpleTool struct. -func NewSimpleTool(name, description string, tools []string, maxTokens *int, model string, cache bool, temperature *float64, args map[string]string, internalPrompt bool, instructions string, jsonResponse bool) *SimpleTool { - return &SimpleTool{ - Name: name, - Description: description, - Tools: tools, - MaxTokens: maxTokens, - Model: model, - Cache: cache, - Temperature: temperature, - Args: args, - InternalPrompt: internalPrompt, - Instructions: instructions, - JSONResponse: jsonResponse, - } -} - -// String method returns the string representation of SimpleTool. -func (t *SimpleTool) String() string { +// String method returns the string representation of ToolDef. +func (t *ToolDef) String() string { var sb strings.Builder if t.Name != "" { @@ -52,13 +43,13 @@ func (t *SimpleTool) String() string { if len(t.Tools) > 0 { sb.WriteString(fmt.Sprintf("Tools: %s\n", strings.Join(t.Tools, ", "))) } - if t.MaxTokens != nil { - sb.WriteString(fmt.Sprintf("Max tokens: %d\n", *t.MaxTokens)) + if t.MaxTokens != 0 { + sb.WriteString(fmt.Sprintf("Max tokens: %d\n", t.MaxTokens)) } - if t.Model != "" { - sb.WriteString(fmt.Sprintf("Model: %s\n", t.Model)) + if t.ModelName != "" { + sb.WriteString(fmt.Sprintf("Model: %s\n", t.ModelName)) } - if !t.Cache { + if t.Cache != nil && !*t.Cache { sb.WriteString("Cache: false\n") } if t.Temperature != nil { @@ -72,7 +63,10 @@ func (t *SimpleTool) String() string { sb.WriteString(fmt.Sprintf("Args: %s: %s\n", arg, desc)) } } - if t.InternalPrompt { + if t.Chat { + sb.WriteString("Chat: true\n") + } + if t.InternalPrompt != nil && *t.InternalPrompt { sb.WriteString("Internal prompt: true\n") } if t.Instructions != "" { @@ -82,24 +76,9 @@ func (t *SimpleTool) String() string { return sb.String() } -// FreeForm struct represents free-form content. -type FreeForm struct { - Content string -} - -// NewFreeForm is a constructor for FreeForm struct. -func NewFreeForm(content string) *FreeForm { - return &FreeForm{Content: content} -} +type ToolDefs []ToolDef -// String method returns the string representation of FreeForm. -func (f *FreeForm) String() string { - return f.Content -} - -type Tools []SimpleTool - -func (t Tools) String() string { +func (t ToolDefs) String() string { resp := make([]string, 0, len(t)) for _, tool := range t { resp = append(resp, tool.String()) @@ -125,10 +104,9 @@ type ToolNode struct { } type Tool struct { - Parameters `json:",inline"` - Instructions string `json:"instructions,omitempty"` - + ToolDef `json:",inline"` ID string `json:"id,omitempty"` + Arguments *openapi3.Schema `json:"arguments,omitempty"` ToolMapping map[string]string `json:"toolMapping,omitempty"` LocalTools map[string]string `json:"localTools,omitempty"` Source ToolSource `json:"source,omitempty"` @@ -149,24 +127,13 @@ type Repo struct { Revision string } -type Parameters struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - MaxTokens int `json:"maxTokens,omitempty"` - ModelName string `json:"modelName,omitempty"` - ModelProvider bool `json:"modelProvider,omitempty"` - JSONResponse bool `json:"jsonResponse,omitempty"` - Chat bool `json:"chat,omitempty"` - Temperature *float32 `json:"temperature,omitempty"` - Cache *bool `json:"cache,omitempty"` - InternalPrompt *bool `json:"internalPrompt"` - Arguments *openapi3.Schema `json:"arguments,omitempty"` - Tools []string `json:"tools,omitempty"` - GlobalTools []string `json:"globalTools,omitempty"` - GlobalModelName string `json:"globalModelName,omitempty"` - Context []string `json:"context,omitempty"` - ExportContext []string `json:"exportContext,omitempty"` - Export []string `json:"export,omitempty"` - Credentials []string `json:"credentials,omitempty"` - Blocking bool `json:"-"` +func concatTools(tools []fmt.Stringer) string { + var sb strings.Builder + for i, tool := range tools { + sb.WriteString(tool.String()) + if i < len(tools)-1 { + sb.WriteString("\n---\n") + } + } + return sb.String() }