From ee5c87c842ae4d5c026d4ddcb015c8e7f3d623b6 Mon Sep 17 00:00:00 2001 From: Donnie Adams Date: Thu, 23 May 2024 20:43:20 -0400 Subject: [PATCH] feat: add support for the new SDK server This change introduces the fork/exec of the SDK server. Then all operations are requests to that server. The starting and stopping of this server is handled by creating/closing clients. This change also introduces a "confirm" implementation that allows users to confirm command execution. Signed-off-by: Donnie Adams --- README.md | 121 ++++++++++++++++--- client.go | 168 +++++++++++++++++--------- client_test.go | 268 ++++++++++++++++++++++++++++++++---------- confirm.go | 7 ++ exec_unix.go | 14 --- exec_windows.go | 18 --- event.go => frame.go | 64 +++++++--- opts.go | 30 +---- readclose.go | 11 -- run.go | 178 ++++++---------------------- test/global-tools.gpt | 2 +- tool.go | 19 ++- 12 files changed, 530 insertions(+), 370 deletions(-) create mode 100644 confirm.go delete mode 100644 exec_unix.go delete mode 100644 exec_windows.go rename event.go => frame.go (50%) delete mode 100644 readclose.go diff --git a/README.md b/README.md index 16363be..3443844 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,7 @@ Additionally, you need the `gptscript` binary. You can install it on your system ## 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. +The client allows the caller to run gptscript files, tools, and other operations (see below). There are currently no options for this client, so calling `NewClient()` is all you need. Although, the intention is that a single client is all you need for the life of your application, you should call `Close()` on the client when you are done. ## Options @@ -32,12 +28,12 @@ None of the options is required, and the defaults will reduce the number of call - `cache`: Enable or disable caching. Default (true). - `cacheDir`: Specify the cache directory. - `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 +- `confirm`: Prompt before running potentially dangerous commands ## Functions @@ -57,7 +53,11 @@ import ( ) func listTools(ctx context.Context) (string, error) { - client := gptscript.NewClient(gptscript.ClientOpts{}) + client, err := gptscript.NewClient() + if err != nil { + return "", err + } + defer client.Close() return client.ListTools(ctx) } ``` @@ -78,7 +78,11 @@ import ( ) func listModels(ctx context.Context) ([]string, error) { - client := gptscript.NewClient(gptscript.ClientOpts{}) + client, err := gptscript.NewClient() + if err != nil { + return nil, err + } + defer client.Close() return client.ListModels(ctx) } ``` @@ -97,7 +101,12 @@ import ( ) func parse(ctx context.Context, fileName string) ([]gptscript.Node, error) { - client := gptscript.NewClient(gptscript.ClientOpts{}) + client, err := gptscript.NewClient() + if err != nil { + return nil, err + } + defer client.Close() + return client.Parse(ctx, fileName) } ``` @@ -116,7 +125,12 @@ import ( ) func parseTool(ctx context.Context, contents string) ([]gptscript.Node, error) { - client := gptscript.NewClient(gptscript.ClientOpts{}) + client, err := gptscript.NewClient() + if err != nil { + return nil, err + } + defer client.Close() + return client.ParseTool(ctx, contents) } ``` @@ -135,7 +149,12 @@ import ( ) func parse(ctx context.Context, nodes []gptscript.Node) (string, error) { - client := gptscript.NewClient(gptscript.ClientOpts{}) + client, err := gptscript.NewClient() + if err != nil { + return "", err + } + defer client.Close() + return client.Fmt(ctx, nodes) } ``` @@ -158,8 +177,13 @@ func runTool(ctx context.Context) (string, error) { Instructions: "who was the president of the united states in 1928?", } - client := gptscript.NewClient(gptscript.ClientOpts{}) - run, err := client.Evaluate(ctx, gptscript.Opts{}, t) + client, err := gptscript.NewClient() + if err != nil { + return "", err + } + defer client.Close() + + run, err := client.Evaluate(ctx, gptscript.Options{}, t) if err != nil { return "", err } @@ -182,12 +206,17 @@ import ( ) func runFile(ctx context.Context) (string, error) { - opts := gptscript.Opts{ + opts := gptscript.Options{ DisableCache: &[]bool{true}[0], Input: "--input hello", } - client := gptscript.NewClient(gptscript.ClientOpts{}) + client, err := gptscript.NewClient() + if err != nil { + return "", err + } + defer client.Close() + run, err := client.Run(ctx, "./hello.gpt", opts) if err != nil { return "", err @@ -217,7 +246,12 @@ func streamExecTool(ctx context.Context) error { Input: "--input world", } - client := gptscript.NewClient(gptscript.ClientOpts{}) + client, err := gptscript.NewClient() + if err != nil { + return "", err + } + defer client.Close() + run, err := client.Run(ctx, "./hello.gpt", opts) if err != nil { return err @@ -233,6 +267,61 @@ func streamExecTool(ctx context.Context) error { } ``` +### Confirm + +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. This also means that `IncludeEvent` should be `true`. + +```go +package main + +import ( + "context" + + "github.com/gptscript-ai/go-gptscript" +) + +func runFileWithConfirm(ctx context.Context) (string, error) { + opts := gptscript.Options{ + DisableCache: &[]bool{true}[0], + Input: "--input hello", + Confirm: true, + IncludeEvents: true, + } + + client, err := gptscript.NewClient() + if err != nil { + return "", err + } + defer client.Close() + + run, err := client.Run(ctx, "./hello.gpt", opts) + if err != nil { + return "", err + } + + for event := range run.Events() { + if event.Type == gptscript.EventTypeCallConfirm { + // event.Tool has the information on the command being run. + // and event.Input will have the input to the command being run. + + err = client.Confirm(ctx, gptscript.AuthResponse{ + ID: event.ID, + Accept: true, // Or false if not allowed. + Message: "", // A message explaining why the command is not allowed (ignored if allowed). + }) + if err != nil { + // Handle error + } + } + + // Process event... + } + + return run.Text() +} +``` + + ## Types ### Tool Parameters diff --git a/client.go b/client.go index 0c49d3f..ef2a25b 100644 --- a/client.go +++ b/client.go @@ -4,39 +4,116 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" + "net/http" "os" + "os/exec" "path/filepath" "strings" + "sync" + "time" +) + +var ( + serverProcess *exec.Cmd + serverProcessCancel context.CancelFunc + clientCount int + lock sync.Mutex ) const relativeToBinaryPath = "" -type ClientOpts struct { - GPTScriptURL string - GPTScriptBin string +type Client interface { + Run(context.Context, string, Options) (*Run, error) + Evaluate(context.Context, Options, ...fmt.Stringer) (*Run, error) + Parse(ctx context.Context, fileName string) ([]Node, error) + ParseTool(ctx context.Context, toolDef string) ([]Node, error) + Version(ctx context.Context) (string, error) + Fmt(ctx context.Context, nodes []Node) (string, error) + ListTools(ctx context.Context) (string, error) + ListModels(ctx context.Context) ([]string, error) + Confirm(ctx context.Context, resp AuthResponse) error + Close() } -type Client struct { - opts ClientOpts +type client struct { + gptscriptURL string } -func NewClient(opts ClientOpts) *Client { - c := &Client{opts: opts} - c.complete() - return c +func NewClient() (Client, error) { + lock.Lock() + defer lock.Unlock() + clientCount++ + + serverURL := os.Getenv("GPTSCRIPT_URL") + if serverURL == "" { + serverURL = "127.0.0.1:9090" + } + + if serverProcessCancel == nil && os.Getenv("GPTSCRIPT_DISABLE_SERVER") != "true" { + ctx, cancel := context.WithCancel(context.Background()) + + in, _ := io.Pipe() + serverProcess = exec.CommandContext(ctx, getCommand(), "--listen-address", serverURL, "sdkserver") + serverProcess.Stdin = in + + serverProcessCancel = func() { + cancel() + _ = in.Close() + } + + if err := serverProcess.Start(); err != nil { + serverProcessCancel() + return nil, fmt.Errorf("failed to start server: %w", err) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := waitForServerReady(timeoutCtx, serverURL); err != nil { + serverProcessCancel() + _ = serverProcess.Wait() + return nil, fmt.Errorf("failed to wait for gptscript to be ready: %w", err) + } + } + return &client{gptscriptURL: "http://" + serverURL}, nil } -func (c *Client) complete() { - if c.opts.GPTScriptBin == "" { - c.opts.GPTScriptBin = getCommand() +func waitForServerReady(ctx context.Context, serverURL string) error { + for { + resp, err := http.Get("http://" + serverURL + "/healthz") + if err != nil { + slog.DebugContext(ctx, "waiting for server to become ready") + } else { + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return nil + } + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + } } } -func (c *Client) Evaluate(ctx context.Context, opts Opts, tools ...fmt.Stringer) (*Run, error) { +func (c *client) Close() { + lock.Lock() + defer lock.Unlock() + clientCount-- + + if clientCount == 0 && serverProcessCancel != nil { + serverProcessCancel() + _ = serverProcess.Wait() + } +} + +func (c *client) Evaluate(ctx context.Context, opts Options, tools ...fmt.Stringer) (*Run, error) { return (&Run{ - url: c.opts.GPTScriptURL, - binPath: c.opts.GPTScriptBin, + url: c.gptscriptURL, requestPath: "evaluate", state: Creating, opts: opts, @@ -45,10 +122,9 @@ func (c *Client) Evaluate(ctx context.Context, opts Opts, tools ...fmt.Stringer) }).NextChat(ctx, opts.Input) } -func (c *Client) Run(ctx context.Context, toolPath string, opts Opts) (*Run, error) { +func (c *client) Run(ctx context.Context, toolPath string, opts Options) (*Run, error) { return (&Run{ - url: c.opts.GPTScriptURL, - binPath: c.opts.GPTScriptBin, + url: c.gptscriptURL, requestPath: "run", state: Creating, opts: opts, @@ -58,8 +134,8 @@ func (c *Client) Run(ctx context.Context, toolPath string, opts Opts) (*Run, err } // 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, "") +func (c *client) Parse(ctx context.Context, fileName string) ([]Node, error) { + out, err := c.runBasicCommand(ctx, "parse", map[string]any{"file": fileName}) if err != nil { return nil, err } @@ -73,8 +149,8 @@ func (c *Client) Parse(ctx context.Context, fileName string) ([]Node, error) { } // 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) +func (c *client) ParseTool(ctx context.Context, toolDef string) ([]Node, error) { + out, err := c.runBasicCommand(ctx, "parse", map[string]any{"content": toolDef}) if err != nil { return nil, err } @@ -88,7 +164,7 @@ func (c *Client) ParseTool(ctx context.Context, toolDef string) ([]Node, error) } // Fmt will format the given nodes into a string. -func (c *Client) Fmt(ctx context.Context, nodes []Node) (string, error) { +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) @@ -96,8 +172,7 @@ func (c *Client) Fmt(ctx context.Context, nodes []Node) (string, error) { run := &runSubCommand{ Run: Run{ - url: c.opts.GPTScriptURL, - binPath: c.opts.GPTScriptBin, + url: c.gptscriptURL, requestPath: "fmt", state: Creating, toolPath: "", @@ -105,12 +180,7 @@ func (c *Client) Fmt(ctx context.Context, nodes []Node) (string, error) { }, } - if run.url != "" { - err = run.request(ctx, Document{Nodes: nodes}) - } else { - err = run.exec(ctx, "fmt") - } - if err != nil { + if err = run.request(ctx, Document{Nodes: nodes}); err != nil { return "", err } @@ -126,8 +196,8 @@ func (c *Client) Fmt(ctx context.Context, nodes []Node) (string, error) { } // Version will return the output of `gptscript --version` -func (c *Client) Version(ctx context.Context) (string, error) { - out, err := c.runBasicCommand(ctx, "--version", "version", "", "") +func (c *client) Version(ctx context.Context) (string, error) { + out, err := c.runBasicCommand(ctx, "version", nil) if err != nil { return "", err } @@ -136,8 +206,8 @@ func (c *Client) Version(ctx context.Context) (string, error) { } // 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", "", "") +func (c *client) ListTools(ctx context.Context) (string, error) { + out, err := c.runBasicCommand(ctx, "list-tools", nil) if err != nil { return "", err } @@ -146,8 +216,8 @@ func (c *Client) ListTools(ctx context.Context) (string, error) { } // 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", "", "") +func (c *client) ListModels(ctx context.Context) ([]string, error) { + out, err := c.runBasicCommand(ctx, "list-models", nil) if err != nil { return nil, err } @@ -155,29 +225,21 @@ func (c *Client) ListModels(ctx context.Context) ([]string, error) { return strings.Split(strings.TrimSpace(out), "\n"), nil } -func (c *Client) runBasicCommand(ctx context.Context, command, requestPath, toolPath, content string) (string, error) { +func (c *client) Confirm(ctx context.Context, resp AuthResponse) error { + _, err := c.runBasicCommand(ctx, "confirm/"+resp.ID, resp) + return err +} + +func (c *client) runBasicCommand(ctx context.Context, requestPath string, body any) (string, error) { run := &runSubCommand{ Run: Run{ - url: c.opts.GPTScriptURL, - binPath: c.opts.GPTScriptBin, + url: c.gptscriptURL, requestPath: requestPath, state: Creating, - toolPath: toolPath, - content: content, }, } - var err error - if run.url != "" { - var m any - if content != "" || toolPath != "" { - m = map[string]any{"content": content, "file": toolPath} - } - err = run.request(ctx, m) - } else { - err = run.exec(ctx, command) - } - if err != nil { + if err := run.request(ctx, body); err != nil { return "", err } diff --git a/client_test.go b/client_test.go index f03357e..a00b3dc 100644 --- a/client_test.go +++ b/client_test.go @@ -13,19 +13,26 @@ import ( "github.com/getkin/kin-openapi/openapi3" ) -var client *Client +var c 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 = NewClient(ClientOpts{GPTScriptURL: os.Getenv("GPTSCRIPT_URL"), GPTScriptBin: os.Getenv("GPTSCRIPT_BIN")}) - os.Exit(m.Run()) + var err error + c, err = NewClient() + if err != nil { + panic(fmt.Sprintf("error creating client: %s", err)) + } + + exitCode := m.Run() + c.Close() + os.Exit(exitCode) } func TestVersion(t *testing.T) { - out, err := client.Version(context.Background()) + out, err := c.Version(context.Background()) if err != nil { t.Errorf("Error getting version: %v", err) } @@ -36,7 +43,7 @@ func TestVersion(t *testing.T) { } func TestListTools(t *testing.T) { - tools, err := client.ListTools(context.Background()) + tools, err := c.ListTools(context.Background()) if err != nil { t.Errorf("Error listing tools: %v", err) } @@ -47,7 +54,7 @@ func TestListTools(t *testing.T) { } func TestListModels(t *testing.T) { - models, err := client.ListModels(context.Background()) + models, err := c.ListModels(context.Background()) if err != nil { t.Errorf("Error listing models: %v", err) } @@ -60,7 +67,7 @@ func TestListModels(t *testing.T) { func TestAbortRun(t *testing.T) { tool := &ToolDef{Instructions: "What is the capital of the united states?"} - run, err := client.Evaluate(context.Background(), Opts{DisableCache: true, IncludeEvents: true}, tool) + run, err := c.Evaluate(context.Background(), Options{DisableCache: true, IncludeEvents: true}, tool) if err != nil { t.Errorf("Error executing tool: %v", err) } @@ -84,7 +91,7 @@ func TestAbortRun(t *testing.T) { func TestSimpleEvaluate(t *testing.T) { tool := &ToolDef{Instructions: "What is the capital of the united states?"} - run, err := client.Evaluate(context.Background(), Opts{}, tool) + run, err := c.Evaluate(context.Background(), Options{}, tool) if err != nil { t.Errorf("Error executing tool: %v", err) } @@ -122,7 +129,7 @@ func TestEvaluateWithContext(t *testing.T) { }, } - run, err := client.Evaluate(context.Background(), Opts{DisableCache: true, IncludeEvents: true}, tool) + run, err := c.Evaluate(context.Background(), Options{DisableCache: true, IncludeEvents: true}, tool) if err != nil { t.Errorf("Error executing tool: %v", err) } @@ -137,27 +144,6 @@ func TestEvaluateWithContext(t *testing.T) { } } -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, @@ -176,7 +162,7 @@ the response should be in JSON and match the format: `, } - run, err := client.Evaluate(context.Background(), Opts{DisableCache: true}, tool) + run, err := c.Evaluate(context.Background(), Options{DisableCache: true}, tool) if err != nil { t.Errorf("Error executing tool: %v", err) } @@ -212,7 +198,7 @@ func TestEvaluateWithToolList(t *testing.T) { }, } - run, err := client.Evaluate(context.Background(), Opts{}, tools...) + run, err := c.Evaluate(context.Background(), Options{}, tools...) if err != nil { t.Errorf("Error executing tool: %v", err) } @@ -253,7 +239,7 @@ func TestEvaluateWithToolListAndSubTool(t *testing.T) { }, } - run, err := client.Evaluate(context.Background(), Opts{SubTool: "other"}, tools...) + run, err := c.Evaluate(context.Background(), Options{SubTool: "other"}, tools...) if err != nil { t.Errorf("Error executing tool: %v", err) } @@ -272,13 +258,17 @@ 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) + run, err := c.Evaluate(context.Background(), Options{IncludeEvents: true}, tool) if err != nil { t.Errorf("Error executing tool: %v", err) } for e := range run.Events() { - eventContent += e.Content + if e.Call != nil { + for _, o := range e.Call.Output { + eventContent += o.Content + } + } } out, err := run.Text() @@ -294,8 +284,8 @@ func TestStreamEvaluate(t *testing.T) { t.Errorf("Unexpected output: %s", out) } - if len(run.ErrorOutput()) == 0 { - t.Error("No stderr output") + if len(run.ErrorOutput()) != 0 { + t.Errorf("Should have no stderr output: %v", run.ErrorOutput()) } } @@ -306,13 +296,17 @@ func TestStreamRun(t *testing.T) { } var eventContent string - run, err := client.Run(context.Background(), wd+"/test/catcher.gpt", Opts{IncludeEvents: true}) + run, err := c.Run(context.Background(), wd+"/test/catcher.gpt", Options{IncludeEvents: true}) if err != nil { t.Errorf("Error executing file: %v", err) } for e := range run.Events() { - eventContent += e.Content + if e.Call != nil { + for _, o := range e.Call.Output { + eventContent += o.Content + } + } } stdErr, err := io.ReadAll(run.stderr) @@ -333,8 +327,8 @@ func TestStreamRun(t *testing.T) { t.Errorf("Unexpected output: %s", out) } - if len(stdErr) == 0 { - t.Error("No stderr output") + if len(stdErr) != 0 { + t.Error("Should have no stderr output") } } @@ -344,7 +338,7 @@ func TestParseSimpleFile(t *testing.T) { t.Fatalf("Error getting working directory: %v", err) } - tools, err := client.Parse(context.Background(), wd+"/test/test.gpt") + tools, err := c.Parse(context.Background(), wd+"/test/test.gpt") if err != nil { t.Errorf("Error parsing file: %v", err) } @@ -363,7 +357,7 @@ func TestParseSimpleFile(t *testing.T) { } func TestParseTool(t *testing.T) { - tools, err := client.ParseTool(context.Background(), "echo hello") + tools, err := c.ParseTool(context.Background(), "echo hello") if err != nil { t.Errorf("Error parsing tool: %v", err) } @@ -382,7 +376,7 @@ func TestParseTool(t *testing.T) { } func TestParseToolWithTextNode(t *testing.T) { - tools, err := client.ParseTool(context.Background(), "echo hello\n---\n!markdown\nhello") + tools, err := c.ParseTool(context.Background(), "echo hello\n---\n!markdown\nhello") if err != nil { t.Errorf("Error parsing tool: %v", err) } @@ -443,7 +437,7 @@ func TestFmt(t *testing.T) { }, } - out, err := client.Fmt(context.Background(), nodes) + out, err := c.Fmt(context.Background(), nodes) if err != nil { t.Errorf("Error formatting nodes: %v", err) } @@ -503,7 +497,7 @@ func TestFmtWithTextNode(t *testing.T) { }, } - out, err := client.Fmt(context.Background(), nodes) + out, err := c.Fmt(context.Background(), nodes) if err != nil { t.Errorf("Error formatting nodes: %v", err) } @@ -533,7 +527,7 @@ func TestToolChat(t *testing.T) { Tools: []string{"sys.chat.finish"}, } - run, err := client.Evaluate(context.Background(), Opts{DisableCache: true}, tool) + run, err := c.Evaluate(context.Background(), Options{DisableCache: true}, tool) if err != nil { t.Fatalf("Error executing tool: %v", err) } @@ -579,7 +573,7 @@ func TestFileChat(t *testing.T) { t.Fatalf("Error getting current working directory: %v", err) } - run, err := client.Run(context.Background(), wd+"/test/chat.gpt", Opts{}) + run, err := c.Run(context.Background(), wd+"/test/chat.gpt", Options{}) if err != nil { t.Fatalf("Error executing tool: %v", err) } @@ -628,24 +622,31 @@ func TestToolWithGlobalTools(t *testing.T) { var eventContent string - run, err := client.Run(context.Background(), wd+"/test/global-tools.gpt", Opts{DisableCache: true, IncludeEvents: true}) + run, err := c.Run(context.Background(), wd+"/test/global-tools.gpt", Options{DisableCache: true, IncludeEvents: true}) if err != nil { t.Errorf("Error executing tool: %v", err) } for e := range run.Events() { - if e.Type == EventTypeRunStart { - runStartSeen = true - } else if e.Type == EventTypeCallStart { - callStartSeen = true - } else if e.Type == EventTypeCallFinish { - callFinishSeen = true - } else if e.Type == EventTypeRunFinish { - runFinishSeen = true - } else if e.Type == EventTypeCallProgress { - callProgressSeen = true + if e.Run != nil { + if e.Run.Type == EventTypeRunStart { + runStartSeen = true + } else if e.Run.Type == EventTypeRunFinish { + runFinishSeen = true + } + } else if e.Call != nil { + if e.Call.Type == EventTypeCallStart { + callStartSeen = true + } else if e.Call.Type == EventTypeCallFinish { + callFinishSeen = true + + for _, o := range e.Call.Output { + eventContent += o.Content + } + } else if e.Call.Type == EventTypeCallProgress { + callProgressSeen = true + } } - eventContent += e.Content } out, err := run.Text() @@ -661,8 +662,8 @@ func TestToolWithGlobalTools(t *testing.T) { t.Errorf("Unexpected output: %s", out) } - if len(run.ErrorOutput()) == 0 { - t.Error("No stderr output") + if len(run.ErrorOutput()) != 0 { + t.Errorf("Should have no stderr output: %v", run.ErrorOutput()) } if !runStartSeen || !callStartSeen || !callFinishSeen || !runFinishSeen || !callProgressSeen { @@ -670,6 +671,149 @@ func TestToolWithGlobalTools(t *testing.T) { } } +func TestConfirm(t *testing.T) { + var eventContent string + tools := []fmt.Stringer{ + &ToolDef{ + Instructions: "List the files in the current directory", + Tools: []string{"sys.exec"}, + }, + } + + run, err := c.Evaluate(context.Background(), Options{IncludeEvents: true, Confirm: true}, tools...) + if err != nil { + t.Errorf("Error executing tool: %v", err) + } + + // Wait for the confirm event + var confirmCallEvent *CallFrame + for e := range run.Events() { + if e.Call != nil { + for _, o := range e.Call.Output { + eventContent += o.Content + } + + if e.Call.Type == EventTypeCallConfirm { + confirmCallEvent = e.Call + break + } + } + } + + if confirmCallEvent == nil { + t.Fatalf("No confirm call event") + } + + if !strings.Contains(confirmCallEvent.Input, "\"ls\"") { + t.Errorf("unexpected confirm input: %s", confirmCallEvent.Input) + } + + if err = c.Confirm(context.Background(), AuthResponse{ + ID: confirmCallEvent.ID, + Accept: true, + }); err != nil { + t.Errorf("Error confirming: %v", err) + } + + // Read the remainder of the events + for e := range run.Events() { + if e.Call != nil { + for _, o := range e.Call.Output { + eventContent += o.Content + } + } + } + + out, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %v", err) + } + + if !strings.Contains(eventContent, "Makefile\nREADME.md") { + t.Errorf("Unexpected event output: %s", eventContent) + } + + if !strings.Contains(out, "Makefile") || !strings.Contains(out, "README.md") { + t.Errorf("Unexpected output: %s", out) + } + + if len(run.ErrorOutput()) != 0 { + t.Errorf("Should have no stderr output: %v", run.ErrorOutput()) + } +} + +func TestConfirmDeny(t *testing.T) { + var eventContent string + tools := []fmt.Stringer{ + &ToolDef{ + Instructions: "List the files in the current directory", + Tools: []string{"sys.exec"}, + }, + } + + run, err := c.Evaluate(context.Background(), Options{IncludeEvents: true, Confirm: true}, tools...) + if err != nil { + t.Errorf("Error executing tool: %v", err) + } + + // Wait for the confirm event + var confirmCallEvent *CallFrame + for e := range run.Events() { + if e.Call != nil { + for _, o := range e.Call.Output { + eventContent += o.Content + } + + if e.Call.Type == EventTypeCallConfirm { + confirmCallEvent = e.Call + break + } + } + } + + if confirmCallEvent == nil { + t.Fatalf("No confirm call event") + } + + if !strings.Contains(confirmCallEvent.Input, "\"ls\"") { + t.Errorf("unexpected confirm input: %s", confirmCallEvent.Input) + } + + if err = c.Confirm(context.Background(), AuthResponse{ + ID: confirmCallEvent.ID, + Accept: false, + Message: "I will not allow it!", + }); err != nil { + t.Errorf("Error confirming: %v", err) + } + + // Read the remainder of the events + for e := range run.Events() { + if e.Call != nil { + for _, o := range e.Call.Output { + eventContent += o.Content + } + } + } + + out, err := run.Text() + if err != nil { + t.Errorf("Error reading output: %v", err) + } + + if !strings.Contains(strings.ToLower(eventContent), "authorization error") { + t.Errorf("Unexpected event output: %s", eventContent) + } + + if !strings.Contains(strings.ToLower(out), "authorization error") { + t.Errorf("Unexpected output: %s", out) + } + + if len(run.ErrorOutput()) != 0 { + t.Errorf("Should have no stderr output: %v", run.ErrorOutput()) + } +} + func TestGetCommand(t *testing.T) { currentEnvVar := os.Getenv("GPTSCRIPT_BIN") t.Cleanup(func() { diff --git a/confirm.go b/confirm.go new file mode 100644 index 0000000..f512e7d --- /dev/null +++ b/confirm.go @@ -0,0 +1,7 @@ +package gptscript + +type AuthResponse struct { + ID string `json:"id"` + Accept bool `json:"accept"` + Message string `json:"message"` +} diff --git a/exec_unix.go b/exec_unix.go deleted file mode 100644 index 5a987d2..0000000 --- a/exec_unix.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !windows - -package gptscript - -import ( - "fmt" - "os" - "os/exec" -) - -func appendExtraFiles(cmd *exec.Cmd, extraFiles ...*os.File) { - cmd.ExtraFiles = append(cmd.ExtraFiles, extraFiles...) - 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/exec_windows.go b/exec_windows.go deleted file mode 100644 index 639667c..0000000 --- a/exec_windows.go +++ /dev/null @@ -1,18 +0,0 @@ -package gptscript - -import ( - "fmt" - "os" - "os/exec" - "syscall" -) - -func appendExtraFiles(cmd *exec.Cmd, extraFiles ...*os.File) { - additionalInheritedHandles := make([]syscall.Handle, 0, len(extraFiles)) - for _, f := range extraFiles { - additionalInheritedHandles = append(additionalInheritedHandles, syscall.Handle(f.Fd())) - } - cmd.SysProcAttr = &syscall.SysProcAttr{AdditionalInheritedHandles: additionalInheritedHandles} - - cmd.Args = append(cmd.Args[:1], append([]string{fmt.Sprintf("--events-stream-to=fd://%d", extraFiles[len(extraFiles)-1].Fd())}, cmd.Args[1:]...)...) -} diff --git a/event.go b/frame.go similarity index 50% rename from event.go rename to frame.go index ddc71ad..7c84ff5 100644 --- a/event.go +++ b/frame.go @@ -1,23 +1,50 @@ 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"` +import ( + "time" +) + +type Frame struct { + Run *RunFrame `json:"run,omitempty"` + Call *CallFrame `json:"call,omitempty"` +} + +type RunFrame struct { + Calls map[string]Call `json:"-"` + ID string `json:"id"` + Program Program `json:"program"` + Input string `json:"input"` + Output string `json:"output"` + Error string `json:"error"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + State RunState `json:"state"` + ChatState any `json:"chatState"` + Type EventType `json:"type"` +} + +type CallFrame struct { + CallContext `json:",inline"` + + Type EventType `json:"type"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Input string `json:"input"` + Output []Output `json:"output"` + Usage Usage `json:"usage"` + LLMRequest any `json:"llmRequest"` + LLMResponse any `json:"llmResponse"` +} + +type Usage struct { + PromptTokens int `json:"promptTokens,omitempty"` + CompletionTokens int `json:"completionTokens,omitempty"` + TotalTokens int `json:"totalTokens,omitempty"` +} + +type Output struct { + Content string `json:"content"` + SubCalls map[string]Call `json:"subCalls"` } type Program struct { @@ -64,6 +91,7 @@ const ( EventTypeCallSubCalls EventType = "callSubCalls" EventTypeCallProgress EventType = "callProgress" EventTypeChat EventType = "callChat" + EventTypeCallConfirm EventType = "callConfirm" EventTypeCallFinish EventType = "callFinish" EventTypeRunFinish EventType = "runFinish" ) diff --git a/opts.go b/opts.go index 4b9fed2..f5d52dd 100644 --- a/opts.go +++ b/opts.go @@ -1,38 +1,14 @@ package gptscript -import ( - "fmt" -) - -// Opts represents options for the gptscript tool or file. -type Opts struct { +// Options represents options for the gptscript tool or file. +type Options struct { + Confirm bool `json:"confirm"` 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/readclose.go b/readclose.go deleted file mode 100644 index fd287e2..0000000 --- a/readclose.go +++ /dev/null @@ -1,11 +0,0 @@ -package gptscript - -import "io" - -// reader is a dummy io.Reader that returns EOF. This is used in situations where errors are returned to allow -// code to always call Read without having to check for nil. -type reader struct{} - -func (r reader) Read([]byte) (int, error) { - return 0, io.EOF -} diff --git a/run.go b/run.go index 73d961f..095b740 100644 --- a/run.go +++ b/run.go @@ -10,28 +10,26 @@ import ( "io" "log/slog" "net/http" - "os" "os/exec" "strconv" - "strings" "sync" ) var errAbortRun = errors.New("run aborted") type Run struct { - url, binPath, requestPath, toolPath, content string - opts Opts - state RunState - chatState string - cancel context.CancelCauseFunc - err error - stdout, stderr io.Reader - wait func() error + url, requestPath, toolPath, content string + opts Options + state RunState + chatState string + cancel context.CancelCauseFunc + err error + stdout, stderr io.Reader + wait func() error rawOutput map[string]any output, errput []byte - events chan Event + events chan Frame lock sync.Mutex complete bool } @@ -74,8 +72,8 @@ 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 { +// Events returns a channel that streams the gptscript events as they occur as Frames. +func (r *Run) Events() <-chan Frame { return r.events } @@ -120,7 +118,6 @@ func (r *Run) NextChat(ctx context.Context, input string) (*Run, error) { run := &Run{ url: r.url, - binPath: r.binPath, requestPath: r.requestPath, state: Creating, chatState: r.chatState, @@ -133,108 +130,34 @@ func (r *Run) NextChat(ctx context.Context, input string) (*Run, error) { 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, - } + var payload any + if r.content != "" { + payload = requestPayload{ + Content: run.content, + Input: input, + Options: 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.WithCancelCause(ctx) - r.cancel = cancel - c, stdout, stderr, err := setupForkCommand(cancelCtx, r.binPath, r.content, r.opts.Input, args, eventsWrite) - if err != nil { - r.err = fmt.Errorf("failed to setup gptscript: %w", err) - r.cancel(r.err) - _ = eventsRead.Close() - r.state = Error - return r.err - } - - if err = c.Start(); err != nil { - r.err = fmt.Errorf("failed to start gptscript: %w", err) - r.cancel(r.err) - _ = eventsRead.Close() - r.state = Error - return r.err - } - - r.state = Running - 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() - if err != nil { - r.state = Error - r.err = fmt.Errorf("failed to wait for gptscript: error: %w, stderr: %s", err, string(r.errput)) - r.cancel(r.err) - } else { - if r.state == Running { - r.state = Finished - } - r.cancel(nil) + } else if run.toolPath != "" { + payload = requestPayload{ + File: run.toolPath, + Input: input, + Options: run.opts, } - return r.err } - return nil + return run, run.request(ctx, payload) } func (r *Run) readEvents(ctx context.Context, events io.Reader) { defer close(r.events) var ( - n int err error frag []byte b = make([]byte, 64*1024) ) - for ; ; n, err = events.Read(b) { - if n == 0 && err != nil { - break - } - + for n := 0; n != 0 || err == nil; n, err = events.Read(b) { if !r.opts.IncludeEvents { continue } @@ -245,7 +168,7 @@ func (r *Run) readEvents(ctx context.Context, events io.Reader) { continue } - var event Event + var event Frame if err := json.Unmarshal(line, &event); err != nil { slog.Debug("failed to unmarshal event", "error", err, "event", string(b)) frag = line[:] @@ -371,7 +294,7 @@ func (r *Run) request(ctx context.Context, payload any) (err error) { return r.err } - if resp.StatusCode != http.StatusOK { + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { r.state = Error r.err = fmt.Errorf("unexpected response status: %s", resp.Status) return r.err @@ -386,12 +309,12 @@ func (r *Run) request(ctx context.Context, payload any) (err error) { r.stdout = stdout r.stderr = stderr - r.events = make(chan Event, 100) + r.events = make(chan Frame, 100) go r.readEvents(cancelCtx, eventsRead) go func() { var ( - n int + err error frag []byte buf = make([]byte, 64*1024) ) @@ -411,11 +334,7 @@ func (r *Run) request(ctx context.Context, payload any) (err error) { resp.Body.Close() }() - for ; ; n, err = resp.Body.Read(buf) { - if n == 0 && err != nil { - break - } - + for n := 0; n != 0 || err == nil; n, err = resp.Body.Read(buf) { for _, line := range bytes.Split(bytes.TrimSpace(append(frag, buf[:n]...)), []byte("\n\n")) { line = bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data: "))) if len(line) == 0 { @@ -485,6 +404,10 @@ func (r *Run) request(ctx context.Context, payload any) (err error) { type RunState string +func (rs RunState) IsTerminal() bool { + return rs == Finished || rs == Error +} + const ( Creating RunState = "creating" Running RunState = "running" @@ -493,39 +416,6 @@ const ( 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 } @@ -586,7 +476,7 @@ type requestPayload struct { Content string `json:"content"` File string `json:"file"` Input string `json:"input"` - Opts `json:",inline"` + Options `json:",inline"` } func isObject(b []byte) bool { diff --git a/test/global-tools.gpt b/test/global-tools.gpt index ce83f9e..cb0f4c0 100644 --- a/test/global-tools.gpt +++ b/test/global-tools.gpt @@ -4,7 +4,7 @@ Runbook 3 --- Name: tool_1 -Global Tools: sys.workspace.ls, sys.workspace.read, sys.workspace.write, github.com/gptscript-ai/knowledge, github.com/drpebcak/duckdb, github.com/gptscript-ai/browser, github.com/gptscript-ai/browser-search/google, github.com/gptscript-ai/browser-search/google-question-answerer +Global Tools: 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 diff --git a/tool.go b/tool.go index 0525def..c51da93 100644 --- a/tool.go +++ b/tool.go @@ -117,12 +117,19 @@ type ToolNode struct { type Tool struct { 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"` - WorkingDir string `json:"workingDir,omitempty"` + ID string `json:"id,omitempty"` + Arguments *openapi3.Schema `json:"arguments,omitempty"` + ToolMapping map[string][]ToolReference `json:"toolMapping,omitempty"` + LocalTools map[string]string `json:"localTools,omitempty"` + Source ToolSource `json:"source,omitempty"` + WorkingDir string `json:"workingDir,omitempty"` +} + +type ToolReference struct { + Named string `json:"named,omitempty"` + Reference string `json:"reference,omitempty"` + Arg string `json:"arg,omitempty"` + ToolID string `json:"toolID,omitempty"` } type ToolSource struct {