From 0a758945566a8281bdd024820bcb0c90fa46b577 Mon Sep 17 00:00:00 2001 From: Donnie Adams <donnie@acorn.io> Date: Thu, 23 May 2024 20:45:22 -0400 Subject: [PATCH] feat: add a new SDK server This server will be used in the SDKs for running gptscripts. This change also includes an implementation that allows the SDKs to "confirm" tools execution. Signed-off-by: Donnie Adams <donnie@acorn.io> --- go.mod | 3 +- go.sum | 6 +- pkg/auth/auth.go | 51 ++++---- pkg/builtin/builtin.go | 242 +++++++++++++++++++++--------------- pkg/cli/eval.go | 20 +-- pkg/cli/gptscript.go | 13 +- pkg/cli/sdk_server.go | 28 +++++ pkg/context/context.go | 29 +++++ pkg/engine/daemon.go | 85 +++++++------ pkg/engine/engine.go | 1 - pkg/engine/http.go | 2 +- pkg/gptscript/gptscript.go | 15 +-- pkg/loader/loader.go | 16 ++- pkg/loader/openapi.go | 24 ++-- pkg/monitor/display.go | 4 +- pkg/monitor/fd.go | 4 +- pkg/mvl/log.go | 18 ++- pkg/parser/parser_test.go | 152 ++++++++++++++-------- pkg/runner/runner.go | 25 ++-- pkg/sdkserver/confirm.go | 87 +++++++++++++ pkg/sdkserver/middleware.go | 65 ++++++++++ pkg/sdkserver/routes.go | 237 +++++++++++++++++++++++++++++++++++ pkg/sdkserver/run.go | 171 +++++++++++++++++++++++++ pkg/sdkserver/server.go | 95 ++++++++++++++ pkg/sdkserver/types.go | 194 +++++++++++++++++++++++++++++ pkg/server/server.go | 14 +-- pkg/types/tool.go | 20 +-- 27 files changed, 1323 insertions(+), 298 deletions(-) create mode 100644 pkg/cli/sdk_server.go create mode 100644 pkg/sdkserver/confirm.go create mode 100644 pkg/sdkserver/middleware.go create mode 100644 pkg/sdkserver/routes.go create mode 100644 pkg/sdkserver/run.go create mode 100644 pkg/sdkserver/server.go create mode 100644 pkg/sdkserver/types.go diff --git a/go.mod b/go.mod index b5585508..49eeb5da 100644 --- a/go.mod +++ b/go.mod @@ -14,13 +14,14 @@ require ( github.com/fatih/color v1.16.0 github.com/getkin/kin-openapi v0.123.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/google/uuid v1.6.0 github.com/gptscript-ai/chat-completion-client v0.0.0-20240515050533-bdef9f2226a9 github.com/hexops/autogold/v2 v2.2.1 github.com/hexops/valast v1.4.4 github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 github.com/mholt/archiver/v4 v4.0.0-alpha.8 github.com/olahol/melody v1.1.4 - github.com/rs/cors v1.10.1 + github.com/rs/cors v1.11.0 github.com/samber/lo v1.38.1 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.0 diff --git a/go.sum b/go.sum index a4cbe1b6..563ffedb 100644 --- a/go.sum +++ b/go.sum @@ -119,6 +119,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= @@ -219,8 +221,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= -github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= +github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 8f8d4544..5c818902 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -15,24 +15,43 @@ import ( func Authorize(ctx engine.Context, input string) (runner.AuthorizerResponse, error) { defer context.GetPauseFuncFromCtx(ctx.Ctx)()() - if !ctx.Tool.IsCommand() { + if IsSafe(ctx) { return runner.AuthorizerResponse{ Accept: true, }, nil } + var result bool + err := survey.AskOne(&survey.Confirm{ + Help: fmt.Sprintf("The full source of the tools is as follows:\n\n%s", ctx.Tool.String()), + Default: true, + Message: ConfirmMessage(ctx, input), + }, &result) + if err != nil { + return runner.AuthorizerResponse{}, err + } + + return runner.AuthorizerResponse{ + Accept: result, + Message: "Request denied, blocking execution.", + }, nil +} + +func IsSafe(ctx engine.Context) bool { + if !ctx.Tool.IsCommand() { + return true + } + + _, ok := builtin.SafeTools[strings.Split(ctx.Tool.Instructions, "\n")[0][2:]] + return ok +} + +func ConfirmMessage(ctx engine.Context, input string) string { var ( - result bool loc = ctx.Tool.Source.Location interpreter = strings.Split(ctx.Tool.Instructions, "\n")[0][2:] ) - if _, ok := builtin.SafeTools[interpreter]; ok { - return runner.AuthorizerResponse{ - Accept: true, - }, nil - } - if ctx.Tool.Source.Repo != nil { loc = ctx.Tool.Source.Repo.Root loc = strings.TrimPrefix(loc, "https://") @@ -44,21 +63,9 @@ func Authorize(ctx engine.Context, input string) (runner.AuthorizerResponse, err loc = "Builtin" } - err := survey.AskOne(&survey.Confirm{ - Help: fmt.Sprintf("The full source of the tools is as follows:\n\n%s", ctx.Tool.String()), - Default: true, - Message: fmt.Sprintf(`Description: %s + return fmt.Sprintf(`Description: %s Interpreter: %s Source: %s Input: %s -Allow the above tool to execute?`, ctx.Tool.Description, interpreter, loc, strings.TrimSpace(input)), - }, &result) - if err != nil { - return runner.AuthorizerResponse{}, err - } - - return runner.AuthorizerResponse{ - Accept: result, - Message: "Request denied, blocking execution.", - }, nil +Allow the above tool to execute?`, ctx.Tool.Description, interpreter, loc, strings.TrimSpace(input)) } diff --git a/pkg/builtin/builtin.go b/pkg/builtin/builtin.go index 7ee3c00c..63e6e215 100644 --- a/pkg/builtin/builtin.go +++ b/pkg/builtin/builtin.go @@ -35,161 +35,197 @@ var SafeTools = map[string]struct{}{ var tools = map[string]types.Tool{ "sys.time.now": { - Parameters: types.Parameters{ - Description: "Returns the current date and time in RFC3339 format", + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Returns the current date and time in RFC3339 format", + }, + BuiltinFunc: SysTimeNow, }, - BuiltinFunc: SysTimeNow, }, "sys.ls": { - Parameters: types.Parameters{ - Description: "Lists the contents of a directory", - Arguments: types.ObjectSchema( - "dir", "The directory to list"), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Lists the contents of a directory", + Arguments: types.ObjectSchema( + "dir", "The directory to list"), + }, + BuiltinFunc: SysLs, }, - BuiltinFunc: SysLs, }, "sys.read": { - Parameters: types.Parameters{ - Description: "Reads the contents of a file", - Arguments: types.ObjectSchema( - "filename", "The name of the file to read"), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Reads the contents of a file", + Arguments: types.ObjectSchema( + "filename", "The name of the file to read"), + }, + BuiltinFunc: SysRead, }, - BuiltinFunc: SysRead, }, "sys.write": { - Parameters: types.Parameters{ - Description: "Write the contents to a file", - Arguments: types.ObjectSchema( - "filename", "The name of the file to write to", - "content", "The content to write"), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Write the contents to a file", + Arguments: types.ObjectSchema( + "filename", "The name of the file to write to", + "content", "The content to write"), + }, + BuiltinFunc: SysWrite, }, - BuiltinFunc: SysWrite, }, "sys.append": { - Parameters: types.Parameters{ - Description: "Appends the contents to a file", - Arguments: types.ObjectSchema( - "filename", "The name of the file to append to", - "content", "The content to append"), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Appends the contents to a file", + Arguments: types.ObjectSchema( + "filename", "The name of the file to append to", + "content", "The content to append"), + }, + BuiltinFunc: SysAppend, }, - BuiltinFunc: SysAppend, }, "sys.http.get": { - Parameters: types.Parameters{ - Description: "Download the contents of a http or https URL", - Arguments: types.ObjectSchema( - "url", "The URL to download"), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Download the contents of a http or https URL", + Arguments: types.ObjectSchema( + "url", "The URL to download"), + }, + BuiltinFunc: SysHTTPGet, }, - BuiltinFunc: SysHTTPGet, }, "sys.http.html2text": { - Parameters: types.Parameters{ - Description: "Download the contents of a http or https URL returning the content as rendered text converted from HTML", - Arguments: types.ObjectSchema( - "url", "The URL to download"), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Download the contents of a http or https URL returning the content as rendered text converted from HTML", + Arguments: types.ObjectSchema( + "url", "The URL to download"), + }, + BuiltinFunc: SysHTTPHtml2Text, }, - BuiltinFunc: SysHTTPHtml2Text, }, "sys.abort": { - Parameters: types.Parameters{ - Description: "Aborts execution", - Arguments: types.ObjectSchema( - "message", "The description of the error or unexpected result that caused abort to be called", - ), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Aborts execution", + Arguments: types.ObjectSchema( + "message", "The description of the error or unexpected result that caused abort to be called", + ), + }, + BuiltinFunc: SysAbort, }, - BuiltinFunc: SysAbort, }, "sys.chat.finish": { - Parameters: types.Parameters{ - Description: "Concludes the conversation. This can not be used to ask a question.", - Arguments: types.ObjectSchema( - "return", "The instructed value to return or a summary of the dialog if no value is instructed", - ), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Concludes the conversation. This can not be used to ask a question.", + Arguments: types.ObjectSchema( + "return", "The instructed value to return or a summary of the dialog if no value is instructed", + ), + }, + BuiltinFunc: SysChatFinish, }, - BuiltinFunc: SysChatFinish, }, "sys.http.post": { - Parameters: types.Parameters{ - Description: "Write contents to a http or https URL using the POST method", - Arguments: types.ObjectSchema( - "url", "The URL to POST to", - "content", "The content to POST", - "contentType", "The \"content type\" of the content such as application/json or text/plain"), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Write contents to a http or https URL using the POST method", + Arguments: types.ObjectSchema( + "url", "The URL to POST to", + "content", "The content to POST", + "contentType", "The \"content type\" of the content such as application/json or text/plain"), + }, + BuiltinFunc: SysHTTPPost, }, - BuiltinFunc: SysHTTPPost, }, "sys.find": { - Parameters: types.Parameters{ - Description: "Traverse a directory looking for files that match a pattern in the style of the unix find command", - Arguments: types.ObjectSchema( - "pattern", "The file pattern to look for. The pattern is a traditional unix glob format with * matching any character and ? matching a single character", - "directory", "The directory to search in. The current directory \".\" will be used as the default if no argument is passed", - ), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Traverse a directory looking for files that match a pattern in the style of the unix find command", + Arguments: types.ObjectSchema( + "pattern", "The file pattern to look for. The pattern is a traditional unix glob format with * matching any character and ? matching a single character", + "directory", "The directory to search in. The current directory \".\" will be used as the default if no argument is passed", + ), + }, + BuiltinFunc: SysFind, }, - BuiltinFunc: SysFind, }, "sys.exec": { - Parameters: types.Parameters{ - Description: "Execute a command and get the output of the command", - Arguments: types.ObjectSchema( - "command", "The command to run including all applicable arguments", - "directory", "The directory to use as the current working directory of the command. The current directory \".\" will be used if no argument is passed", - ), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Execute a command and get the output of the command", + Arguments: types.ObjectSchema( + "command", "The command to run including all applicable arguments", + "directory", "The directory to use as the current working directory of the command. The current directory \".\" will be used if no argument is passed", + ), + }, + BuiltinFunc: SysExec, }, - BuiltinFunc: SysExec, }, "sys.getenv": { - Parameters: types.Parameters{ - Description: "Gets the value of an OS environment variable", - Arguments: types.ObjectSchema( - "name", "The environment variable name to lookup"), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Gets the value of an OS environment variable", + Arguments: types.ObjectSchema( + "name", "The environment variable name to lookup"), + }, + BuiltinFunc: SysGetenv, }, - BuiltinFunc: SysGetenv, }, "sys.download": { - Parameters: types.Parameters{ - Description: "Downloads a URL, saving the contents to disk at a given location", - Arguments: types.ObjectSchema( - "url", "The URL to download, either http or https.", - "location", "(optional) The on disk location to store the file. If no location is specified a temp location will be used. If the target file already exists it will fail unless override is set to true.", - "override", "If true and a file at the location exists, the file will be overwritten, otherwise fail. Default is false"), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Downloads a URL, saving the contents to disk at a given location", + Arguments: types.ObjectSchema( + "url", "The URL to download, either http or https.", + "location", "(optional) The on disk location to store the file. If no location is specified a temp location will be used. If the target file already exists it will fail unless override is set to true.", + "override", "If true and a file at the location exists, the file will be overwritten, otherwise fail. Default is false"), + }, + BuiltinFunc: SysDownload, }, - BuiltinFunc: SysDownload, }, "sys.remove": { - Parameters: types.Parameters{ - Description: "Removes the specified files", - Arguments: types.ObjectSchema( - "location", "The file to remove"), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Removes the specified files", + Arguments: types.ObjectSchema( + "location", "The file to remove"), + }, + BuiltinFunc: SysRemove, }, - BuiltinFunc: SysRemove, }, "sys.stat": { - Parameters: types.Parameters{ - Description: "Gets size, modfied time, and mode of the specified file", - Arguments: types.ObjectSchema( - "filepath", "The complete path and filename of the file", - ), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Gets size, modfied time, and mode of the specified file", + Arguments: types.ObjectSchema( + "filepath", "The complete path and filename of the file", + ), + }, + BuiltinFunc: SysStat, }, - BuiltinFunc: SysStat, }, "sys.prompt": { - Parameters: types.Parameters{ - Description: "Prompts the user for input", - Arguments: types.ObjectSchema( - "message", "The message to display to the user", - "fields", "A comma-separated list of fields to prompt for", - "sensitive", "(true or false) Whether the input should be hidden", - ), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Prompts the user for input", + Arguments: types.ObjectSchema( + "message", "The message to display to the user", + "fields", "A comma-separated list of fields to prompt for", + "sensitive", "(true or false) Whether the input should be hidden", + ), + }, + BuiltinFunc: SysPrompt, }, - BuiltinFunc: SysPrompt, }, "sys.chat.history": { - Parameters: types.Parameters{ - Description: "Retrieves the previous chat dialog", - Arguments: types.ObjectSchema(), + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "Retrieves the previous chat dialog", + Arguments: types.ObjectSchema(), + }, + BuiltinFunc: SysChatHistory, }, - BuiltinFunc: SysChatHistory, }, } diff --git a/pkg/cli/eval.go b/pkg/cli/eval.go index addab89d..cc13290e 100644 --- a/pkg/cli/eval.go +++ b/pkg/cli/eval.go @@ -28,16 +28,18 @@ type Eval struct { func (e *Eval) Run(cmd *cobra.Command, args []string) error { tool := types.Tool{ - Parameters: types.Parameters{ - Description: "inline script", - Tools: e.Tools, - MaxTokens: e.MaxTokens, - ModelName: e.Model, - JSONResponse: e.JSON, - InternalPrompt: e.InternalPrompt, - Chat: e.Chat, + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: "inline script", + Tools: e.Tools, + MaxTokens: e.MaxTokens, + ModelName: e.Model, + JSONResponse: e.JSON, + InternalPrompt: e.InternalPrompt, + Chat: e.Chat, + }, + Instructions: strings.Join(args, " "), }, - Instructions: strings.Join(args, " "), } if e.Temperature != "" { diff --git a/pkg/cli/gptscript.go b/pkg/cli/gptscript.go index 38502ceb..25575527 100644 --- a/pkg/cli/gptscript.go +++ b/pkg/cli/gptscript.go @@ -73,12 +73,13 @@ func New() *cobra.Command { root := &GPTScript{} command := cmd.Command( root, - &Eval{ - gptscript: root, - }, + &Eval{gptscript: root}, &Credential{root: root}, &Parse{}, &Fmt{}, + &SDKServer{ + GPTScript: root, + }, ) // Hide all the global flags for the credential subcommand. @@ -290,7 +291,7 @@ func (r *GPTScript) readProgram(ctx context.Context, runner *gptscript.GPTScript } func (r *GPTScript) PrintOutput(toolInput, toolOutput string) (err error) { - if r.Output != "" { + if r.Output != "" && r.Output != "-" { err = os.WriteFile(r.Output, []byte(toolOutput), 0644) if err != nil { return err @@ -328,7 +329,7 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) { if err != nil { return err } - defer s.Close() + defer s.Close(true) return s.Start(ctx) } @@ -336,7 +337,7 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) { if err != nil { return err } - defer gptScript.Close() + defer gptScript.Close(true) if r.ListModels { return r.listModels(ctx, gptScript, args) diff --git a/pkg/cli/sdk_server.go b/pkg/cli/sdk_server.go new file mode 100644 index 00000000..29700549 --- /dev/null +++ b/pkg/cli/sdk_server.go @@ -0,0 +1,28 @@ +package cli + +import ( + "github.com/gptscript-ai/gptscript/pkg/sdkserver" + "github.com/spf13/cobra" +) + +type SDKServer struct { + *GPTScript +} + +func (c *SDKServer) Customize(cmd *cobra.Command) { + cmd.Args = cobra.NoArgs + cmd.Hidden = true +} + +func (c *SDKServer) Run(cmd *cobra.Command, _ []string) error { + opts, err := c.NewGPTScriptOpts() + if err != nil { + return err + } + + return sdkserver.Start(cmd.Context(), sdkserver.Options{ + Options: opts, + ListenAddress: c.ListenAddress, + Debug: c.Debug, + }) +} diff --git a/pkg/context/context.go b/pkg/context/context.go index 3ec88c21..31474f6c 100644 --- a/pkg/context/context.go +++ b/pkg/context/context.go @@ -2,6 +2,9 @@ package context import ( "context" + + "github.com/google/uuid" + "github.com/gptscript-ai/gptscript/pkg/mvl" ) type pauseKey struct{} @@ -17,3 +20,29 @@ func GetPauseFuncFromCtx(ctx context.Context) func() func() { } return pauseF } + +type reqIDKey struct{} + +func WithNewRequestID(ctx context.Context) context.Context { + return context.WithValue(ctx, reqIDKey{}, uuid.NewString()) +} + +func GetRequestID(ctx context.Context) string { + s, _ := ctx.Value(reqIDKey{}).(string) + return s +} + +type loggerKey struct{} + +func WithLogger(ctx context.Context, log mvl.Logger) context.Context { + return context.WithValue(ctx, loggerKey{}, log) +} + +func GetLogger(ctx context.Context) mvl.Logger { + l, ok := ctx.Value(loggerKey{}).(mvl.Logger) + if !ok { + return mvl.New("") + } + + return l +} diff --git a/pkg/engine/daemon.go b/pkg/engine/daemon.go index 00c8e79c..a797cc76 100644 --- a/pkg/engine/daemon.go +++ b/pkg/engine/daemon.go @@ -14,6 +14,8 @@ import ( "github.com/gptscript-ai/gptscript/pkg/types" ) +var ports Ports + type Ports struct { daemonPorts map[string]int64 daemonLock sync.Mutex @@ -25,32 +27,36 @@ type Ports struct { daemonWG sync.WaitGroup } -func (p *Ports) SetPorts(start, end int64) { - p.startPort = start - p.endPort = end +func SetPorts(start, end int64) { + ports.daemonLock.Lock() + defer ports.daemonLock.Unlock() + if ports.startPort == 0 && ports.endPort == 0 { + ports.startPort = start + ports.endPort = end + } } -func (p *Ports) CloseDaemons() { - p.daemonLock.Lock() - if p.daemonCtx == nil { - p.daemonLock.Unlock() +func CloseDaemons() { + ports.daemonLock.Lock() + if ports.daemonCtx == nil { + ports.daemonLock.Unlock() return } - p.daemonLock.Unlock() + ports.daemonLock.Unlock() - p.daemonClose() - p.daemonWG.Wait() + ports.daemonClose() + ports.daemonWG.Wait() } -func (p *Ports) NextPort() int64 { - if p.startPort == 0 { - p.startPort = 10240 - p.endPort = 11240 +func nextPort() int64 { + if ports.startPort == 0 { + ports.startPort = 10240 + ports.endPort = 11240 } // This is pretty simple and inefficient approach, but also never releases ports - count := p.endPort - p.startPort + 1 + count := ports.endPort - ports.startPort + 1 toTry := make([]int64, 0, count) - for i := p.startPort; i <= p.endPort; i++ { + for i := ports.startPort; i <= ports.endPort; i++ { toTry = append(toTry, i) } @@ -59,13 +65,13 @@ func (p *Ports) NextPort() int64 { }) for _, nextPort := range toTry { - if _, ok := p.usedPorts[nextPort]; ok { + if _, ok := ports.usedPorts[nextPort]; ok { continue } - if p.usedPorts == nil { - p.usedPorts = map[int64]struct{}{} + if ports.usedPorts == nil { + ports.usedPorts = map[int64]struct{}{} } - p.usedPorts[nextPort] = struct{}{} + ports.usedPorts[nextPort] = struct{}{} return nextPort } @@ -93,26 +99,31 @@ func getPath(instructions string) (string, string) { return strings.TrimSpace(rest), strings.TrimSpace(value) } -func (e *Engine) startDaemon(_ context.Context, tool types.Tool) (string, error) { - e.Ports.daemonLock.Lock() - defer e.Ports.daemonLock.Unlock() +func (e *Engine) startDaemon(tool types.Tool) (string, error) { + ports.daemonLock.Lock() + defer ports.daemonLock.Unlock() instructions := strings.TrimPrefix(tool.Instructions, types.DaemonPrefix) instructions, path := getPath(instructions) tool.Instructions = types.CommandPrefix + instructions - port, ok := e.Ports.daemonPorts[tool.ID] + port, ok := ports.daemonPorts[tool.ID] url := fmt.Sprintf("http://127.0.0.1:%d%s", port, path) if ok { return url, nil } - if e.Ports.daemonCtx == nil { - e.Ports.daemonCtx, e.Ports.daemonClose = context.WithCancel(context.Background()) + if ports.daemonCtx == nil { + var cancel func() + ports.daemonCtx, cancel = context.WithCancel(context.Background()) + ports.daemonClose = func() { + cancel() + ports.daemonCtx = nil + } } - ctx := e.Ports.daemonCtx - port = e.Ports.NextPort() + ctx := ports.daemonCtx + port = nextPort() url = fmt.Sprintf("http://127.0.0.1:%d%s", port, path) cmd, stop, err := e.newCommand(ctx, []string{ @@ -149,15 +160,15 @@ func (e *Engine) startDaemon(_ context.Context, tool types.Tool) (string, error) return url, err } - if e.Ports.daemonPorts == nil { - e.Ports.daemonPorts = map[string]int64{} + if ports.daemonPorts == nil { + ports.daemonPorts = map[string]int64{} } - e.Ports.daemonPorts[tool.ID] = port + ports.daemonPorts[tool.ID] = port killedCtx, cancel := context.WithCancelCause(ctx) defer cancel(nil) - e.Ports.daemonWG.Add(1) + ports.daemonWG.Add(1) go func() { err := cmd.Wait() if err != nil { @@ -168,11 +179,11 @@ func (e *Engine) startDaemon(_ context.Context, tool types.Tool) (string, error) cancel(err) stop() - e.Ports.daemonLock.Lock() - defer e.Ports.daemonLock.Unlock() + ports.daemonLock.Lock() + defer ports.daemonLock.Unlock() - delete(e.Ports.daemonPorts, tool.ID) - e.Ports.daemonWG.Done() + delete(ports.daemonPorts, tool.ID) + ports.daemonWG.Done() }() for i := 0; i < 120; i++ { @@ -195,7 +206,7 @@ func (e *Engine) startDaemon(_ context.Context, tool types.Tool) (string, error) } func (e *Engine) runDaemon(ctx context.Context, prg *types.Program, tool types.Tool, input string) (cmdRet *Return, cmdErr error) { - url, err := e.startDaemon(ctx, tool) + url, err := e.startDaemon(tool) if err != nil { return nil, err } diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index a6eb9bdb..501cbe65 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -26,7 +26,6 @@ type Engine struct { RuntimeManager RuntimeManager Env []string Progress chan<- types.CompletionStatus - Ports *Ports } type State struct { diff --git a/pkg/engine/http.go b/pkg/engine/http.go index 94f741dc..a81f1bb3 100644 --- a/pkg/engine/http.go +++ b/pkg/engine/http.go @@ -43,7 +43,7 @@ func (e *Engine) runHTTP(ctx context.Context, prg *types.Program, tool types.Too if !ok { return nil, fmt.Errorf("failed to find tool [%s] for [%s]", referencedToolName, parsed.Hostname()) } - toolURL, err = e.startDaemon(ctx, referencedTool) + toolURL, err = e.startDaemon(referencedTool) if err != nil { return nil, err } diff --git a/pkg/gptscript/gptscript.go b/pkg/gptscript/gptscript.go index ad48eed5..d079e845 100644 --- a/pkg/gptscript/gptscript.go +++ b/pkg/gptscript/gptscript.go @@ -65,10 +65,10 @@ func New(opts *Options) (*GPTScript, error) { return nil, err } - oAIClient, err := openai.NewClient(append([]openai.Options{opts.OpenAI}, openai.Options{ + oAIClient, err := openai.NewClient(opts.OpenAI, openai.Options{ Cache: cacheClient, SetSeed: true, - })...) + }) if err != nil { return nil, err } @@ -78,9 +78,7 @@ func New(opts *Options) (*GPTScript, error) { } if opts.Runner.MonitorFactory == nil { - opts.Runner.MonitorFactory = monitor.NewConsole(append([]monitor.Options{opts.Monitor}, monitor.Options{ - DisplayProgress: !*opts.Quiet, - })...) + opts.Runner.MonitorFactory = monitor.NewConsole(opts.Monitor, monitor.Options{DebugMessages: *opts.Quiet}) } if opts.Runner.RuntimeManager == nil { @@ -148,13 +146,16 @@ func (g *GPTScript) Run(ctx context.Context, prg types.Program, envs []string, i return g.Runner.Run(ctx, prg, envs, input) } -func (g *GPTScript) Close() { - g.Runner.Close() +func (g *GPTScript) Close(closeDaemons bool) { if g.DeleteWorkspaceOnClose && g.WorkspacePath != "" { if err := os.RemoveAll(g.WorkspacePath); err != nil { log.Errorf("failed to delete workspace %s: %s", g.WorkspacePath, err) } } + + if closeDaemons { + engine.CloseDaemons() + } } func (g *GPTScript) GetModel() engine.Model { diff --git a/pkg/loader/loader.go b/pkg/loader/loader.go index ad80555d..36c920c4 100644 --- a/pkg/loader/loader.go +++ b/pkg/loader/loader.go @@ -101,9 +101,7 @@ func loadLocal(base *source, name string) (*source, bool, error) { } func loadProgram(data []byte, into *types.Program, targetToolName string) (types.Tool, error) { - var ( - ext types.Program - ) + var ext types.Program if err := json.Unmarshal(data[len(assemble.Header):], &ext); err != nil { return types.Tool{}, err @@ -192,9 +190,7 @@ func readTool(ctx context.Context, cache *cache.Client, prg *types.Program, base return []types.Tool{tool}, nil } - var ( - tools []types.Tool - ) + var tools []types.Tool if openAPIDocument := loadOpenAPI(prg, data); openAPIDocument != nil { var err error @@ -211,10 +207,12 @@ func readTool(ctx context.Context, cache *cache.Client, prg *types.Program, base if ext := path.Ext(base.Name); len(tools) == 0 && ext != "" && ext != system.Suffix && utf8.Valid(data) { tools = []types.Tool{ { - Parameters: types.Parameters{ - Name: base.Name, + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: base.Name, + }, + Instructions: types.EchoPrefix + "\n" + string(data), }, - Instructions: types.EchoPrefix + "\n" + string(data), }, } } diff --git a/pkg/loader/openapi.go b/pkg/loader/openapi.go index 658a26ab..bedb92a8 100644 --- a/pkg/loader/openapi.go +++ b/pkg/loader/openapi.go @@ -131,13 +131,15 @@ func getOpenAPITools(t *openapi3.T, defaultHost string) ([]types.Tool, error) { bodyMIME string ) tool := types.Tool{ - Parameters: types.Parameters{ - Name: operation.OperationID, - Description: toolDesc, - Arguments: &openapi3.Schema{ - Type: "object", - Properties: openapi3.Schemas{}, - Required: []string{}, + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: operation.OperationID, + Description: toolDesc, + Arguments: &openapi3.Schema{ + Type: "object", + Properties: openapi3.Schemas{}, + Required: []string{}, + }, }, }, Source: types.ToolSource{ @@ -279,9 +281,11 @@ func getOpenAPITools(t *openapi3.T, defaultHost string) ([]types.Tool, error) { // The first tool we generate is a special tool that just exports all the others. exportTool := types.Tool{ - Parameters: types.Parameters{ - Description: fmt.Sprintf("This is a tool set for the %s OpenAPI spec", t.Info.Title), - Export: toolNames, + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Description: fmt.Sprintf("This is a tool set for the %s OpenAPI spec", t.Info.Title), + Export: toolNames, + }, }, Source: types.ToolSource{ LineNo: 0, diff --git a/pkg/monitor/display.go b/pkg/monitor/display.go index 9cdae6c2..2ddb236e 100644 --- a/pkg/monitor/display.go +++ b/pkg/monitor/display.go @@ -51,7 +51,7 @@ func (c *Console) Start(_ context.Context, prg *types.Program, _ []string, input mon.dump.Program = prg mon.dump.Input = input - log.Fields("runID", mon.dump.ID, "input", input, "program", prg, "type", "runStart").Debugf("Run started") + log.Fields("runID", mon.dump.ID, "input", input, "program", prg, "type", runner.EventTypeRunStart).Debugf("Run started") return mon, nil } @@ -288,7 +288,7 @@ func (d *display) Stop(output string, err error) { d.callLock.Lock() defer d.callLock.Unlock() - log.Fields("runID", d.dump.ID, "output", output, "err", err, "type", "runFinish").Debugf("Run stopped") + log.Fields("runID", d.dump.ID, "output", output, "err", err, "type", runner.EventTypeRunFinish).Debugf("Run stopped") if d.usage.TotalTokens > 0 { log.Fields("runID", d.dump.ID, "total", d.usage.TotalTokens, "prompt", d.usage.PromptTokens, "completion", d.usage.CompletionTokens).Infof("usage ") } diff --git a/pkg/monitor/fd.go b/pkg/monitor/fd.go index 6ec4d38c..f5fea312 100644 --- a/pkg/monitor/fd.go +++ b/pkg/monitor/fd.go @@ -66,7 +66,7 @@ func (s fileFactory) Start(_ context.Context, prg *types.Program, env []string, fd.event(Event{ Event: runner.Event{ Time: time.Now(), - Type: "runStart", + Type: runner.EventTypeRunStart, }, Program: prg, }) @@ -107,7 +107,7 @@ func (f *fd) Stop(output string, err error) { e := Event{ Event: runner.Event{ Time: time.Now(), - Type: "runFinish", + Type: runner.EventTypeRunFinish, }, Input: f.input, Output: output, diff --git a/pkg/mvl/log.go b/pkg/mvl/log.go index 1523982f..a7cacf9b 100644 --- a/pkg/mvl/log.go +++ b/pkg/mvl/log.go @@ -91,6 +91,19 @@ func Package() Logger { return New(p) } +func NewWithFields(fields logrus.Fields) Logger { + return Logger{ + log: logrus.StandardLogger(), + fields: fields, + } +} + +func NewWithID(id string) Logger { + return NewWithFields(logrus.Fields{ + "id": id, + }) +} + func New(name string) Logger { var fields logrus.Fields if name != "" { @@ -98,10 +111,7 @@ func New(name string) Logger { "logger": name, } } - return Logger{ - log: logrus.StandardLogger(), - fields: fields, - } + return NewWithFields(fields) } func SetOutput(out io.Writer) { diff --git a/pkg/parser/parser_test.go b/pkg/parser/parser_test.go index 88fee31a..9cdb593e 100644 --- a/pkg/parser/parser_test.go +++ b/pkg/parser/parser_test.go @@ -24,7 +24,7 @@ func TestHashBang(t *testing.T) { } func TestParseGlobals(t *testing.T) { - var input = ` + input := ` global tools: foo, bar global model: the model --- @@ -36,39 +36,49 @@ tools: bar }) require.NoError(t, err) autogold.Expect(Document{Nodes: []Node{ - {ToolNode: &ToolNode{ - Tool: types.Tool{ - Parameters: types.Parameters{ - ModelName: "the model", - Tools: []string{ - "foo", - "bar", + { + ToolNode: &ToolNode{ + Tool: types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + ModelName: "the model", + Tools: []string{ + "foo", + "bar", + }, + GlobalTools: []string{ + "foo", + "bar", + }, + GlobalModelName: "the model", + }, }, - GlobalTools: []string{ - "foo", - "bar", - }, - GlobalModelName: "the model", + Source: types.ToolSource{LineNo: 1}, }, - Source: types.ToolSource{LineNo: 1}, }, - }}, - {ToolNode: &ToolNode{Tool: types.Tool{ - Parameters: types.Parameters{ - Name: "bar", - ModelName: "the model", - Tools: []string{ - "bar", - "foo", + }, + { + ToolNode: &ToolNode{ + Tool: types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: "bar", + ModelName: "the model", + Tools: []string{ + "bar", + "foo", + }, + }, + }, + Source: types.ToolSource{LineNo: 5}, }, }, - Source: types.ToolSource{LineNo: 5}, - }}}, + }, }}).Equal(t, out) } func TestParseSkip(t *testing.T) { - var input = ` + input := ` first --- name: second @@ -102,30 +112,58 @@ name: seven out, err := Parse(strings.NewReader(input)) require.NoError(t, err) autogold.Expect(Document{Nodes: []Node{ - {ToolNode: &ToolNode{ - Tool: types.Tool{ - Instructions: "first", - Source: types.ToolSource{ - LineNo: 1, + { + ToolNode: &ToolNode{ + Tool: types.Tool{ + ToolDef: types.ToolDef{ + Instructions: "first", + }, + Source: types.ToolSource{ + LineNo: 1, + }, }, }, - }}, - {ToolNode: &ToolNode{Tool: types.Tool{ - Parameters: types.Parameters{Name: "second"}, - Source: types.ToolSource{LineNo: 4}, - }}}, - {TextNode: &TextNode{Text: "!third\n\nname: third\n"}}, - {ToolNode: &ToolNode{Tool: types.Tool{ - Parameters: types.Parameters{Name: "fourth"}, - Instructions: "!forth dont skip", - Source: types.ToolSource{LineNo: 11}, - }}}, - {ToolNode: &ToolNode{Tool: types.Tool{ - Parameters: types.Parameters{Name: "fifth"}, - Instructions: "#!ignore", - Source: types.ToolSource{LineNo: 14}, - }}}, - {TextNode: &TextNode{Text: `!skip + }, + { + ToolNode: &ToolNode{ + Tool: types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{Name: "second"}, + }, + Source: types.ToolSource{LineNo: 4}, + }, + }, + }, + { + TextNode: &TextNode{ + Text: "!third\n\nname: third\n", + }, + }, + { + ToolNode: &ToolNode{ + Tool: types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{Name: "fourth"}, + Instructions: "!forth dont skip", + }, + Source: types.ToolSource{LineNo: 11}, + }, + }, + }, + { + ToolNode: &ToolNode{ + Tool: types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{Name: "fifth"}, + Instructions: "#!ignore", + }, + Source: types.ToolSource{LineNo: 14}, + }, + }, + }, + { + TextNode: &TextNode{ + Text: `!skip name: six ---- @@ -136,12 +174,20 @@ name: bad name: bad --- name: bad -`}}, - {ToolNode: &ToolNode{Tool: types.Tool{ - Parameters: types.Parameters{ - Name: "seven", +`, + }, + }, + { + ToolNode: &ToolNode{ + Tool: types.Tool{ + ToolDef: types.ToolDef{ + Parameters: types.Parameters{ + Name: "seven", + }, + }, + Source: types.ToolSource{LineNo: 30}, + }, }, - Source: types.ToolSource{LineNo: 30}, - }}}, + }, }}).Equal(t, out) } diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 00ba524d..a4877472 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -46,7 +46,7 @@ type AuthorizerResponse struct { type AuthorizerFunc func(ctx engine.Context, input string) (AuthorizerResponse, error) -func DefaultAuthorizer(_ engine.Context, _ string) (AuthorizerResponse, error) { +func DefaultAuthorizer(engine.Context, string) (AuthorizerResponse, error) { return AuthorizerResponse{ Accept: true, }, nil @@ -84,7 +84,6 @@ type Runner struct { auth AuthorizerFunc factory MonitorFactory runtimeManager engine.RuntimeManager - ports engine.Ports credCtx string credMutex sync.Mutex credOverrides string @@ -109,16 +108,12 @@ func New(client engine.Model, credCtx string, opts ...Options) (*Runner, error) if opt.EndPort < opt.StartPort { return nil, fmt.Errorf("invalid port range: %d-%d", opt.StartPort, opt.EndPort) } - runner.ports.SetPorts(opt.StartPort, opt.EndPort) + engine.SetPorts(opt.StartPort, opt.EndPort) } return runner, nil } -func (r *Runner) Close() { - r.ports.CloseDaemons() -} - type ChatResponse struct { Done bool `json:"done"` Content string `json:"content"` @@ -226,12 +221,14 @@ type Event struct { type EventType string var ( - EventTypeCallStart = EventType("callStart") - EventTypeCallContinue = EventType("callContinue") - EventTypeCallSubCalls = EventType("callSubCalls") - EventTypeCallProgress = EventType("callProgress") - EventTypeChat = EventType("callChat") - EventTypeCallFinish = EventType("callFinish") + EventTypeRunStart EventType = "runStart" + EventTypeCallStart EventType = "callStart" + EventTypeCallContinue EventType = "callContinue" + EventTypeCallSubCalls EventType = "callSubCalls" + EventTypeCallProgress EventType = "callProgress" + EventTypeChat EventType = "callChat" + EventTypeCallFinish EventType = "callFinish" + EventTypeRunFinish EventType = "runFinish" ) func getContextInput(prg *types.Program, ref types.ToolReference, input string) (string, error) { @@ -422,7 +419,6 @@ func (r *Runner) start(callCtx engine.Context, state *State, monitor Monitor, en RuntimeManager: r.runtimeManager, Progress: progress, Env: env, - Ports: &r.ports, } callCtx.Ctx = context2.AddPauseFuncToCtx(callCtx.Ctx, monitor.Pause) @@ -601,7 +597,6 @@ func (r *Runner) resume(callCtx engine.Context, monitor Monitor, env []string, s RuntimeManager: r.runtimeManager, Progress: progress, Env: env, - Ports: &r.ports, } var ( diff --git a/pkg/sdkserver/confirm.go b/pkg/sdkserver/confirm.go new file mode 100644 index 00000000..2ed34bb3 --- /dev/null +++ b/pkg/sdkserver/confirm.go @@ -0,0 +1,87 @@ +package sdkserver + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/gptscript-ai/gptscript/pkg/auth" + gcontext "github.com/gptscript-ai/gptscript/pkg/context" + "github.com/gptscript-ai/gptscript/pkg/engine" + "github.com/gptscript-ai/gptscript/pkg/runner" + gserver "github.com/gptscript-ai/gptscript/pkg/server" +) + +func (s *server) authorize(ctx engine.Context, input string) (runner.AuthorizerResponse, error) { + if auth.IsSafe(ctx) { + return runner.AuthorizerResponse{ + Accept: true, + }, nil + } + + s.lock.RLock() + authChan := s.waitingToConfirm[ctx.ID] + s.lock.RUnlock() + + if authChan != nil { + return runner.AuthorizerResponse{}, fmt.Errorf("authorize called multiple times for same ID: %s", ctx.ID) + } + + runID := gserver.RunIDFromContext(ctx.Ctx) + s.lock.Lock() + authChan = make(chan runner.AuthorizerResponse) + s.waitingToConfirm[ctx.ID] = authChan + s.lock.Unlock() + defer func(id string) { + s.lock.Lock() + delete(s.waitingToConfirm, id) + s.lock.Unlock() + }(ctx.ID) + + s.events.C <- gserver.Event{ + Event: runner.Event{ + Time: time.Now(), + CallContext: ctx.GetCallContext(), + Type: CallConfirm, + }, + Input: input, + RunID: runID, + } + + // Wait for the confirmation to come through. + select { + case <-ctx.Ctx.Done(): + return runner.AuthorizerResponse{}, ctx.Ctx.Err() + case authResponse := <-authChan: + return authResponse, nil + } +} + +func (s *server) confirm(w http.ResponseWriter, r *http.Request) { + logger := gcontext.GetLogger(r.Context()) + id := r.PathValue("id") + + s.lock.RLock() + authChan := s.waitingToConfirm[id] + s.lock.RUnlock() + + if authChan == nil { + writeError(logger, w, http.StatusNotFound, fmt.Errorf("no confirmation found with id %q", id)) + return + } + + var authResponse runner.AuthorizerResponse + if err := json.NewDecoder(r.Body).Decode(&authResponse); err != nil { + writeError(logger, w, http.StatusBadRequest, fmt.Errorf("failed to decode request body: %w", err)) + return + } + + // Don't block here because, if the authorizer is no longer waiting on this then it will never unblock. + select { + case authChan <- authResponse: + w.WriteHeader(http.StatusAccepted) + default: + w.WriteHeader(http.StatusConflict) + } +} diff --git a/pkg/sdkserver/middleware.go b/pkg/sdkserver/middleware.go new file mode 100644 index 00000000..887ff639 --- /dev/null +++ b/pkg/sdkserver/middleware.go @@ -0,0 +1,65 @@ +package sdkserver + +import ( + "net/http" + "runtime/debug" + + "github.com/gptscript-ai/gptscript/pkg/context" + "github.com/gptscript-ai/gptscript/pkg/mvl" +) + +type middleware func(http.Handler) http.Handler + +func apply(h http.Handler, m ...func(http.Handler) http.Handler) http.Handler { + for i := len(m) - 1; i >= 0; i-- { + h = m[i](h) + } + return h +} + +func contentType(contentTypes ...string) middleware { + return func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, ct := range contentTypes { + w.Header().Add("Content-Type", ct) + } + h.ServeHTTP(w, r) + }) + } +} + +func logRequest(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := context.GetLogger(r.Context()) + + defer func() { + if err := recover(); err != nil { + l.Fields("stack", string(debug.Stack())).Errorf("Panic: %v", err) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"stderr": "encountered an unexpected error"}`)) + } + }() + + l.Infof("Handling request: method %s, path %s", r.Method, r.URL.Path) + h.ServeHTTP(w, r) + l.Infof("Handled request: method %s, path %s", r.Method, r.URL.Path) + }) +} + +func addRequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(context.WithNewRequestID(r.Context()))) + }) +} + +func addLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP( + w, + r.WithContext(context.WithLogger( + r.Context(), + mvl.NewWithID(context.GetRequestID(r.Context())), + )), + ) + }) +} diff --git a/pkg/sdkserver/routes.go b/pkg/sdkserver/routes.go new file mode 100644 index 00000000..913e5a77 --- /dev/null +++ b/pkg/sdkserver/routes.go @@ -0,0 +1,237 @@ +package sdkserver + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/acorn-io/broadcaster" + gcontext "github.com/gptscript-ai/gptscript/pkg/context" + "github.com/gptscript-ai/gptscript/pkg/gptscript" + "github.com/gptscript-ai/gptscript/pkg/loader" + "github.com/gptscript-ai/gptscript/pkg/parser" + "github.com/gptscript-ai/gptscript/pkg/runner" + gserver "github.com/gptscript-ai/gptscript/pkg/server" + "github.com/gptscript-ai/gptscript/pkg/types" + "github.com/gptscript-ai/gptscript/pkg/version" +) + +const toolRunTimeout = 15 * time.Minute + +type server struct { + client *gptscript.GPTScript + events *broadcaster.Broadcaster[gserver.Event] + + lock sync.RWMutex + waitingToConfirm map[string]chan runner.AuthorizerResponse +} + +func (s *server) addRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /healthz", s.health) + + mux.HandleFunc("GET /version", s.version) + + // Listing tools supports listing system tools (GET) or listing tools in a gptscript (POST). + mux.HandleFunc("POST /list-tools", s.listTools) + mux.HandleFunc("GET /list-tools", s.listTools) + // Listing models supports listing OpenAI models (GET) or listing models from providers (POST). + mux.HandleFunc("POST /list-models", s.listModels) + mux.HandleFunc("GET /list-models", s.listModels) + + mux.HandleFunc("POST /run", s.execHandler) + mux.HandleFunc("POST /evaluate", s.execHandler) + + mux.HandleFunc("POST /parse", s.parse) + mux.HandleFunc("POST /fmt", s.fmtDocument) + + mux.HandleFunc("POST /confirm/{id}", s.confirm) +} + +// health just provides an endpoint for checking whether the server is running and accessible. +func (s *server) health(w http.ResponseWriter, r *http.Request) { + writeResponse(gcontext.GetLogger(r.Context()), w, map[string]string{"stdout": "ok"}) +} + +// version will return the output of `gptscript --version` +func (s *server) version(w http.ResponseWriter, r *http.Request) { + writeResponse(gcontext.GetLogger(r.Context()), w, map[string]any{"stdout": fmt.Sprintf("%s version %s", version.ProgramName, version.Get().String())}) +} + +// listTools will return the output of `gptscript --list-tools` +func (s *server) listTools(w http.ResponseWriter, r *http.Request) { + logger := gcontext.GetLogger(r.Context()) + var prg types.Program + if r.ContentLength != 0 { + reqObject := new(toolOrFileRequest) + err := json.NewDecoder(r.Body).Decode(reqObject) + if err != nil { + writeError(logger, w, http.StatusBadRequest, fmt.Errorf("failed to decode request body: %w", err)) + return + } + + if reqObject.Content != "" { + prg, err = loader.ProgramFromSource(r.Context(), reqObject.Content, reqObject.SubTool, loader.Options{Cache: s.client.Cache}) + } else if reqObject.File != "" { + prg, err = loader.Program(r.Context(), reqObject.File, reqObject.SubTool, loader.Options{Cache: s.client.Cache}) + } else { + prg, err = loader.ProgramFromSource(r.Context(), reqObject.ToolDef.String(), reqObject.SubTool, loader.Options{Cache: s.client.Cache}) + } + if err != nil { + writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to load program: %w", err)) + return + } + } + + tools := s.client.ListTools(r.Context(), prg) + sort.Slice(tools, func(i, j int) bool { + return tools[i].Name < tools[j].Name + }) + + lines := make([]string, 0, len(tools)) + for _, tool := range tools { + if tool.Name == "" { + tool.Name = prg.Name + } + + // Don't print instructions + tool.Instructions = "" + + lines = append(lines, tool.String()) + } + + writeResponse(logger, w, map[string]any{"stdout": strings.Join(lines, "\n---\n")}) +} + +// listModels will return the output of `gptscript --list-models` +func (s *server) listModels(w http.ResponseWriter, r *http.Request) { + logger := gcontext.GetLogger(r.Context()) + var providers []string + if r.ContentLength != 0 { + reqObject := new(modelsRequest) + if err := json.NewDecoder(r.Body).Decode(reqObject); err != nil { + writeError(logger, w, http.StatusBadRequest, fmt.Errorf("failed to decode request body: %w", err)) + return + } + + providers = reqObject.Providers + } + + out, err := s.client.ListModels(r.Context(), providers...) + if err != nil { + writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to list models: %w", err)) + return + } + + writeResponse(logger, w, map[string]any{"stdout": strings.Join(out, "\n")}) +} + +// execHandler is a general handler for executing tools with gptscript. This is mainly responsible for parsing the request body. +// Then the options and tool are passed to the process function. +func (s *server) execHandler(w http.ResponseWriter, r *http.Request) { + logger := gcontext.GetLogger(r.Context()) + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to read request body: %w", err)) + return + } + + reqObject := new(toolOrFileRequest) + if err := json.Unmarshal(body, reqObject); err != nil { + writeError(logger, w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err)) + return + } + + ctx, cancel := context.WithTimeout(gserver.ContextWithNewRunID(r.Context()), toolRunTimeout) + defer cancel() + + // Ensure chat state is not empty. + if reqObject.ChatState == "" { + reqObject.ChatState = "null" + } + + logger.Debugf("executing tool: %+v", reqObject) + var ( + def fmt.Stringer = &reqObject.ToolDef + programLoader loaderFunc = loader.ProgramFromSource + ) + if reqObject.Content != "" { + def = &reqObject.content + } else if reqObject.File != "" { + def = &reqObject.file + programLoader = loader.Program + } + + opts := &gptscript.Options{ + Cache: reqObject.Options, + Env: reqObject.Env, + Workspace: reqObject.Workspace, + CredentialContext: reqObject.CredentialContext, + Runner: runner.Options{ + // Set the monitor factory so that we can get events from the server. + MonitorFactory: gserver.NewSessionFactory(s.events), + }, + } + + if reqObject.Confirm { + opts.Runner.Authorizer = s.authorize + } + + s.execAndStream(ctx, programLoader, logger, w, opts, reqObject.ChatState, reqObject.Input, reqObject.SubTool, def) +} + +// parse will parse the file and return the corresponding Document. +func (s *server) parse(w http.ResponseWriter, r *http.Request) { + logger := gcontext.GetLogger(r.Context()) + reqObject := new(parseRequest) + if err := json.NewDecoder(r.Body).Decode(reqObject); err != nil { + writeError(logger, w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err)) + return + } + + logger.Debugf("parsing file: file=%s, content=%s", reqObject.File, reqObject.Content) + + var ( + out parser.Document + err error + ) + + if reqObject.Content != "" { + out, err = parser.Parse(strings.NewReader(reqObject.Content), reqObject.Options) + } else { + var file *os.File + file, err = os.Open(reqObject.File) + if err != nil { + logger.Errorf("failed to open file: %v", err) + writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to open file: %w", err)) + return + } + out, err = parser.Parse(file, reqObject.Options) + _ = file.Close() + } + if err != nil { + logger.Errorf("failed to parse file: %v", err) + writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to parse file: %w", err)) + return + } + + writeResponse(logger, w, map[string]any{"stdout": map[string]any{"nodes": out.Nodes}}) +} + +// fmtDocument will produce a string representation of the document. +func (s *server) fmtDocument(w http.ResponseWriter, r *http.Request) { + logger := gcontext.GetLogger(r.Context()) + doc := new(parser.Document) + if err := json.NewDecoder(r.Body).Decode(doc); err != nil { + writeError(logger, w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err)) + return + } + + writeResponse(logger, w, map[string]string{"stdout": doc.String()}) +} diff --git a/pkg/sdkserver/run.go b/pkg/sdkserver/run.go new file mode 100644 index 00000000..97194f87 --- /dev/null +++ b/pkg/sdkserver/run.go @@ -0,0 +1,171 @@ +package sdkserver + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gptscript-ai/gptscript/pkg/gptscript" + "github.com/gptscript-ai/gptscript/pkg/loader" + "github.com/gptscript-ai/gptscript/pkg/mvl" + "github.com/gptscript-ai/gptscript/pkg/runner" + gserver "github.com/gptscript-ai/gptscript/pkg/server" + "github.com/gptscript-ai/gptscript/pkg/types" +) + +type loaderFunc func(context.Context, string, string, ...loader.Options) (types.Program, error) + +func (s *server) execAndStream(ctx context.Context, programLoader loaderFunc, logger mvl.Logger, w http.ResponseWriter, opts *gptscript.Options, chatState, input, subTool string, toolDef fmt.Stringer) { + g, err := gptscript.New(opts) + if err != nil { + writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to initialize gptscript: %w", err)) + return + } + defer g.Close(false) + + prg, err := programLoader(ctx, toolDef.String(), subTool, loader.Options{Cache: g.Cache}) + if err != nil { + writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to load program: %w", err)) + return + } + + errChan := make(chan error) + programOutput := make(chan runner.ChatResponse) + events := s.events.Subscribe() + defer events.Close() + + go func() { + run, err := g.Chat(ctx, chatState, prg, opts.Env, input) + if err != nil { + errChan <- err + } else { + programOutput <- run + } + close(errChan) + close(programOutput) + }() + + processEventStreamOutput(ctx, logger, w, gserver.RunIDFromContext(ctx), events.C, programOutput, errChan) +} + +// processEventStreamOutput will stream the events of the tool to the response as server sent events. +// If an error occurs, then an event with the error will also be sent. +func processEventStreamOutput(ctx context.Context, logger mvl.Logger, w http.ResponseWriter, id string, events <-chan gserver.Event, output <-chan runner.ChatResponse, errChan chan error) { + run := newRun(id) + setStreamingHeaders(w) + + streamEvents(ctx, logger, w, run, events) + + var out runner.ChatResponse + select { + case <-ctx.Done(): + case out = <-output: + run.processStdout(out) + + writeServerSentEvent(logger, w, map[string]any{ + "stdout": out, + }) + case err := <-errChan: + writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to run file: %w", err)) + } + + // Now that we have received all events, send the DONE event. + _, err := w.Write([]byte("data: [DONE]\n\n")) + if err == nil { + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + + logger.Debugf("wrote DONE event") +} + +// streamEvents will stream the events of the tool to the response as server sent events. +func streamEvents(ctx context.Context, logger mvl.Logger, w http.ResponseWriter, run *runInfo, events <-chan gserver.Event) { + logger.Debugf("receiving events") + for { + select { + case <-ctx.Done(): + logger.Debugf("context canceled while receiving events") + go func() { + //nolint:revive + for range events { + } + }() + return + case e, ok := <-events: + if ok && e.RunID != run.ID { + continue + } + + if !ok { + logger.Debugf("done receiving events") + return + } + + writeServerSentEvent(logger, w, run.process(e)) + + if e.Type == runner.EventTypeRunFinish { + logger.Debugf("finished receiving events") + return + } + } + } +} + +func writeResponse(logger mvl.Logger, w http.ResponseWriter, v any) { + b, err := json.Marshal(v) + if err != nil { + writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to marshal response: %w", err)) + return + } + + _, _ = w.Write(b) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + +func writeError(logger mvl.Logger, w http.ResponseWriter, code int, err error) { + logger.Debugf("Writing error response with code %d: %v", code, err) + + w.WriteHeader(code) + resp := map[string]any{ + "stderr": err.Error(), + } + + b, err := json.Marshal(resp) + if err != nil { + _, _ = w.Write([]byte(fmt.Sprintf(`{"stderr": "%s"}`, err.Error()))) + return + } + + _, _ = w.Write(b) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + +func writeServerSentEvent(logger mvl.Logger, w http.ResponseWriter, event any) { + ev, err := json.Marshal(event) + if err != nil { + logger.Warnf("failed to marshal event: %v", err) + return + } + + _, err = w.Write([]byte(fmt.Sprintf("data: %s\n\n", ev))) + if err == nil { + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + + logger.Debugf("wrote event: %v", string(ev)) +} + +func setStreamingHeaders(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") +} diff --git a/pkg/sdkserver/server.go b/pkg/sdkserver/server.go new file mode 100644 index 00000000..2a134af7 --- /dev/null +++ b/pkg/sdkserver/server.go @@ -0,0 +1,95 @@ +package sdkserver + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/acorn-io/broadcaster" + "github.com/gptscript-ai/gptscript/pkg/gptscript" + "github.com/gptscript-ai/gptscript/pkg/mvl" + "github.com/gptscript-ai/gptscript/pkg/runner" + gserver "github.com/gptscript-ai/gptscript/pkg/server" + "github.com/rs/cors" +) + +type Options struct { + gptscript.Options + + ListenAddress string + Debug bool +} + +func Start(ctx context.Context, opts Options) error { + sigCtx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGKILL) + defer cancel() + go func() { + // This is a hack. This server will be run as a forked process in the SDKs. The SDKs will hold stdin open for as long + // as it wants the server running. When stdin is closed (or the parent process dies), then this will unblock and the + // server will be shutdown. + _, _ = io.ReadAll(os.Stdin) + cancel() + }() + + if opts.Debug { + mvl.SetDebug() + } + + events := broadcaster.New[gserver.Event]() + opts.Options.Runner.MonitorFactory = gserver.NewSessionFactory(events) + go events.Start(ctx) + + g, err := gptscript.New(&opts.Options) + if err != nil { + return err + } + + s := &server{ + client: g, + events: events, + waitingToConfirm: make(map[string]chan runner.AuthorizerResponse), + } + defer s.Close() + + s.addRoutes(http.DefaultServeMux) + + server := http.Server{ + Addr: opts.ListenAddress, + Handler: apply(http.DefaultServeMux, + contentType("application/json"), + addRequestID, + addLogger, + logRequest, + cors.Default().Handler, + ), + } + + slog.Info("Starting server", "addr", server.Addr) + + context.AfterFunc(sigCtx, func() { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + slog.Info("Shutting down server") + _ = server.Shutdown(ctx) + slog.Info("Server stopped") + }) + + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("server error: %w", err) + } + + return nil +} + +func (s *server) Close() { + s.client.Close(true) + s.events.Close() +} diff --git a/pkg/sdkserver/types.go b/pkg/sdkserver/types.go new file mode 100644 index 00000000..9f4f5e31 --- /dev/null +++ b/pkg/sdkserver/types.go @@ -0,0 +1,194 @@ +package sdkserver + +import ( + "maps" + "time" + + "github.com/gptscript-ai/gptscript/pkg/cache" + "github.com/gptscript-ai/gptscript/pkg/engine" + "github.com/gptscript-ai/gptscript/pkg/parser" + "github.com/gptscript-ai/gptscript/pkg/runner" + gserver "github.com/gptscript-ai/gptscript/pkg/server" + "github.com/gptscript-ai/gptscript/pkg/types" +) + +type runState string + +const ( + Creating runState = "creating" + Running runState = "running" + Continue runState = "continue" + Finished runState = "finished" + Error runState = "error" + + CallConfirm runner.EventType = "callConfirm" +) + +type toolOrFileRequest struct { + cache.Options `json:",inline"` + types.ToolDef `json:",inline"` + content `json:",inline"` + file `json:",inline"` + + SubTool string `json:"subTool"` + Input string `json:"input"` + ChatState string `json:"chatState"` + Workspace string `json:"workspace"` + Env []string `json:"env"` + CredentialContext string `json:"credentialContext"` + Confirm bool `json:"confirm"` +} + +type content struct { + Content string `json:"content"` +} + +func (c *content) String() string { + return c.Content +} + +type file struct { + File string `json:"file"` +} + +func (f *file) String() string { + return f.File +} + +type parseRequest struct { + parser.Options `json:",inline"` + content `json:",inline"` + + File string `json:"file"` +} + +type modelsRequest struct { + Providers []string `json:"providers"` +} + +type runInfo struct { + Calls map[string]call `json:"-"` + ID string `json:"id"` + Program types.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"` +} + +func newRun(id string) *runInfo { + return &runInfo{ + ID: id, + State: Creating, + Calls: make(map[string]call), + } +} + +type runEvent struct { + runInfo `json:",inline"` + + Type runner.EventType `json:"type"` +} + +func (r *runInfo) process(event gserver.Event) map[string]any { + switch event.Type { + case runner.EventTypeRunStart: + r.Start = event.Time + r.Program = *event.Program + r.State = Running + case runner.EventTypeRunFinish: + r.End = event.Time + r.Output = event.Output + r.Error = event.Err + if r.Error != "" { + r.State = Error + } else { + r.State = Finished + } + } + + if event.CallContext == nil || event.CallContext.ID == "" { + return map[string]any{"run": runEvent{ + runInfo: *r, + Type: event.Type, + }} + } + + call := r.Calls[event.CallContext.ID] + call.CallContext = *event.CallContext + call.Type = event.Type + + switch event.Type { + case runner.EventTypeCallStart: + call.Start = event.Time + call.Input = event.Content + + case runner.EventTypeCallSubCalls: + call.setSubCalls(event.ToolSubCalls) + + case runner.EventTypeCallProgress: + call.setOutput(event.Content) + + case runner.EventTypeCallFinish: + call.End = event.Time + call.setOutput(event.Content) + + case runner.EventTypeChat: + if event.ChatRequest != nil { + call.LLMRequest = event.ChatRequest + } + if event.ChatResponse != nil { + call.LLMResponse = event.ChatResponse + } + } + + r.Calls[event.CallContext.ID] = call + return map[string]any{"call": call} +} + +func (r *runInfo) processStdout(cs runner.ChatResponse) { + if cs.Done { + r.State = Finished + } else { + r.State = Continue + } + + r.ChatState = cs.State +} + +type call struct { + engine.CallContext `json:",inline"` + + Type runner.EventType `json:"type"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Input string `json:"input"` + Output []output `json:"output"` + Usage types.Usage `json:"usage"` + LLMRequest any `json:"llmRequest"` + LLMResponse any `json:"llmResponse"` +} + +func (c *call) setSubCalls(subCalls map[string]engine.Call) { + c.Output = append(c.Output, output{ + SubCalls: maps.Clone(subCalls), + }) +} + +func (c *call) setOutput(o string) { + if len(c.Output) == 0 || len(c.Output[len(c.Output)-1].SubCalls) > 0 { + c.Output = append(c.Output, output{ + Content: o, + }) + } else { + c.Output[len(c.Output)-1].Content = o + } +} + +type output struct { + Content string `json:"content"` + SubCalls map[string]engine.Call `json:"subCalls"` +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 3b96d670..d1605886 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -89,16 +89,16 @@ var ( type execKey struct{} -func ContextWithNewID(ctx context.Context) context.Context { +func ContextWithNewRunID(ctx context.Context) context.Context { return context.WithValue(ctx, execKey{}, counter.Next()) } -func IDFromContext(ctx context.Context) string { +func RunIDFromContext(ctx context.Context) string { return ctx.Value(execKey{}).(string) } -func (s *Server) Close() { - s.runner.Close() +func (s *Server) Close(closeDaemons bool) { + s.runner.Close(closeDaemons) } func (s *Server) list(rw http.ResponseWriter, req *http.Request) { @@ -304,12 +304,12 @@ func NewSessionFactory(events *broadcaster.Broadcaster[Event]) *SessionFactory { } func (s SessionFactory) Start(ctx context.Context, prg *types.Program, env []string, input string) (runner.Monitor, error) { - id := IDFromContext(ctx) + id := RunIDFromContext(ctx) s.events.C <- Event{ Event: runner.Event{ Time: time.Now(), - Type: "runStart", + Type: runner.EventTypeRunStart, }, RunID: id, Program: prg, @@ -347,7 +347,7 @@ func (s *Session) Stop(output string, err error) { e := Event{ Event: runner.Event{ Time: time.Now(), - Type: "runFinish", + Type: runner.EventTypeRunFinish, }, RunID: s.id, Input: s.input, diff --git a/pkg/types/tool.go b/pkg/types/tool.go index 98bcce64..c4d20303 100644 --- a/pkg/types/tool.go +++ b/pkg/types/tool.go @@ -69,8 +69,10 @@ func (p Program) GetContextToolRefs(toolID string) ([]ToolReference, error) { func (p Program) GetCompletionTools() (result []CompletionTool, err error) { return Tool{ - Parameters: Parameters{ - Tools: []string{"main"}, + ToolDef: ToolDef{ + Parameters: Parameters{ + Tools: []string{"main"}, + }, }, ToolMapping: map[string][]ToolReference{ "main": { @@ -125,14 +127,18 @@ type Parameters struct { Blocking bool `json:"-"` } -type Tool struct { +type ToolDef struct { Parameters `json:",inline"` - Instructions string `json:"instructions,omitempty"` + Instructions string `json:"instructions,omitempty"` + BuiltinFunc BuiltinFunc `json:"-"` +} + +type Tool struct { + ToolDef `json:",inline"` ID string `json:"id,omitempty"` ToolMapping map[string][]ToolReference `json:"toolMapping,omitempty"` LocalTools map[string]string `json:"localTools,omitempty"` - BuiltinFunc BuiltinFunc `json:"-"` Source ToolSource `json:"source,omitempty"` WorkingDir string `json:"workingDir,omitempty"` } @@ -215,7 +221,7 @@ func (t Tool) GetToolRefsFromNames(names []string) (result []ToolReference, _ er return } -func (t Tool) String() string { +func (t ToolDef) String() string { buf := &strings.Builder{} if t.Parameters.GlobalModelName != "" { _, _ = fmt.Fprintf(buf, "Global Model Name: %s\n", t.Parameters.GlobalModelName) @@ -276,7 +282,7 @@ func (t Tool) String() string { if len(t.Parameters.Credentials) > 0 { _, _ = fmt.Fprintf(buf, "Credentials: %s\n", strings.Join(t.Parameters.Credentials, ", ")) } - if t.Chat { + if t.Parameters.Chat { _, _ = fmt.Fprintf(buf, "Chat: true\n") }