From b77cd130a99f9abb4720b13c52dac18ee5cad2c8 Mon Sep 17 00:00:00 2001 From: Grant Linville Date: Tue, 6 Aug 2024 09:39:59 -0400 Subject: [PATCH 01/21] enhance: credentials: add GPTSCRIPT_CREDENTIAL_EXPIRATION (#709) Signed-off-by: Grant Linville --- docs/docs/03-tools/04-credential-tools.md | 18 ++++++++ integration/cred_test.go | 24 ++++++++-- integration/scripts/cred_expiration.gpt | 46 +++++++++++++++++++ .../{credscopes.gpt => cred_scopes.gpt} | 0 pkg/config/cliconfig.go | 2 + pkg/credentials/credential.go | 1 + pkg/runner/runner.go | 9 ++++ 7 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 integration/scripts/cred_expiration.gpt rename integration/scripts/{credscopes.gpt => cred_scopes.gpt} (100%) diff --git a/docs/docs/03-tools/04-credential-tools.md b/docs/docs/03-tools/04-credential-tools.md index 3e6a678a..1911dc34 100644 --- a/docs/docs/03-tools/04-credential-tools.md +++ b/docs/docs/03-tools/04-credential-tools.md @@ -204,3 +204,21 @@ that environment variable, and if it is set, get the refresh token from the exis typically without user interaction. For an example of a tool that uses the refresh feature, see the [Gateway OAuth2 tool](https://github.com/gptscript-ai/gateway-oauth2). + +### GPTSCRIPT_CREDENTIAL_EXPIRATION environment variable + +When a tool references a credential tool, GPTScript will add the environment variables from the credential to the tool's +environment before executing the tool. If at least one of the credentials has an `expiresAt` field, GPTScript will also +set the environment variable `GPTSCRIPT_CREDENTIAL_EXPIRATION`, which contains the nearest expiration time out of all +credentials referenced by the tool, in RFC 3339 format. That way, it can be referenced in the tool body if needed. +Here is an example: + +``` +Credential: my-credential-tool.gpt as myCred + +#!python3 + +import os + +print("myCred expires at " + os.getenv("GPTSCRIPT_CREDENTIAL_EXPIRATION", "")) +``` diff --git a/integration/cred_test.go b/integration/cred_test.go index 67298ef8..d77f096c 100644 --- a/integration/cred_test.go +++ b/integration/cred_test.go @@ -1,7 +1,9 @@ package integration import ( + "strings" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -15,15 +17,31 @@ func TestGPTScriptCredential(t *testing.T) { // TestCredentialScopes makes sure that environment variables set by credential tools and shared credential tools // are only available to the correct tools. See scripts/credscopes.gpt for more details. func TestCredentialScopes(t *testing.T) { - out, err := RunScript("scripts/credscopes.gpt", "--sub-tool", "oneOne") + out, err := RunScript("scripts/cred_scopes.gpt", "--sub-tool", "oneOne") require.NoError(t, err) require.Contains(t, out, "good") - out, err = RunScript("scripts/credscopes.gpt", "--sub-tool", "twoOne") + out, err = RunScript("scripts/cred_scopes.gpt", "--sub-tool", "twoOne") require.NoError(t, err) require.Contains(t, out, "good") - out, err = RunScript("scripts/credscopes.gpt", "--sub-tool", "twoTwo") + out, err = RunScript("scripts/cred_scopes.gpt", "--sub-tool", "twoTwo") require.NoError(t, err) require.Contains(t, out, "good") } + +// TestCredentialExpirationEnv tests a GPTScript with two credentials that expire at different times. +// One expires after two hours, and the other expires after one hour. +// This test makes sure that the GPTSCRIPT_CREDENTIAL_EXPIRATION environment variable is set to the nearer expiration time (1h). +func TestCredentialExpirationEnv(t *testing.T) { + out, err := RunScript("scripts/cred_expiration.gpt") + require.NoError(t, err) + + for _, line := range strings.Split(out, "\n") { + if timestamp, found := strings.CutPrefix(line, "Expires: "); found { + expiresTime, err := time.Parse(time.RFC3339, timestamp) + require.NoError(t, err) + require.True(t, time.Until(expiresTime) < time.Hour) + } + } +} diff --git a/integration/scripts/cred_expiration.gpt b/integration/scripts/cred_expiration.gpt new file mode 100644 index 00000000..da535df0 --- /dev/null +++ b/integration/scripts/cred_expiration.gpt @@ -0,0 +1,46 @@ +cred: credentialTool with 2 as hours +cred: credentialTool with 1 as hours + +#!python3 + +import os + +print("Expires: " + os.getenv("GPTSCRIPT_CREDENTIAL_EXPIRATION", ""), end="") + +--- +name: credentialTool +args: hours: the number of hours from now to expire + +#!python3 + +import os +import json +from datetime import datetime, timedelta, timezone + +class Output: + def __init__(self, env, expires_at): + self.env = env + self.expiresAt = expires_at + + def to_dict(self): + return { + "env": self.env, + "expiresAt": self.expiresAt.isoformat() + } + +hours_str = os.getenv("HOURS") +if hours_str is None: + print("HOURS environment variable is not set") + os._exit(1) + +try: + hours = int(hours_str) +except ValueError: + print("failed to parse HOURS") + os._exit(1) + +expires_at = datetime.now(timezone.utc) + timedelta(hours=hours) +out = Output(env={"yeet": "yote"}, expires_at=expires_at) +out_json = json.dumps(out.to_dict()) + +print(out_json) diff --git a/integration/scripts/credscopes.gpt b/integration/scripts/cred_scopes.gpt similarity index 100% rename from integration/scripts/credscopes.gpt rename to integration/scripts/cred_scopes.gpt diff --git a/pkg/config/cliconfig.go b/pkg/config/cliconfig.go index e4aa49ab..7970415f 100644 --- a/pkg/config/cliconfig.go +++ b/pkg/config/cliconfig.go @@ -55,6 +55,8 @@ type CLIConfig struct { Auths map[string]AuthConfig `json:"auths,omitempty"` CredentialsStore string `json:"credsStore,omitempty"` GPTScriptConfigFile string `json:"gptscriptConfig,omitempty"` + GatewayURL string `json:"gatewayURL,omitempty"` + Integrations map[string]string `json:"integrations,omitempty"` auths map[string]types.AuthConfig authsLock *sync.Mutex diff --git a/pkg/credentials/credential.go b/pkg/credentials/credential.go index 605208a0..3d1e2192 100644 --- a/pkg/credentials/credential.go +++ b/pkg/credentials/credential.go @@ -16,6 +16,7 @@ const ( CredentialTypeTool CredentialType = "tool" CredentialTypeModelProvider CredentialType = "modelProvider" ExistingCredential = "GPTSCRIPT_EXISTING_CREDENTIAL" + CredentialExpiration = "GPTSCRIPT_CREDENTIAL_EXPIRATION" ) type Credential struct { diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 3a33c720..c2137bea 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -865,6 +865,7 @@ func (r *Runner) handleCredentials(callCtx engine.Context, monitor Monitor, env } } + var nearestExpiration *time.Time for _, ref := range credToolRefs { toolName, credentialAlias, args, err := types.ParseCredentialArgs(ref.Reference, callCtx.Input) if err != nil { @@ -967,11 +968,19 @@ func (r *Runner) handleCredentials(callCtx engine.Context, monitor Monitor, env } else { log.Warnf("Not saving credential for tool %s - credentials will only be saved for tools from GitHub, or tools that use aliases.", toolName) } + + if c.ExpiresAt != nil && (nearestExpiration == nil || nearestExpiration.After(*c.ExpiresAt)) { + nearestExpiration = c.ExpiresAt + } } for k, v := range c.Env { env = append(env, fmt.Sprintf("%s=%s", k, v)) } + + if nearestExpiration != nil { + env = append(env, fmt.Sprintf("%s=%s", credentials.CredentialExpiration, nearestExpiration.Format(time.RFC3339))) + } } return env, nil From 0f820879b595fe317a659dd53f67b8fd2826a958 Mon Sep 17 00:00:00 2001 From: Darren Shepherd Date: Sun, 2 Jun 2024 22:46:37 -0700 Subject: [PATCH 02/21] feat: add basic bash support for windows --- go.mod | 12 +-- go.sum | 24 ++--- pkg/engine/cmd.go | 5 + pkg/repos/download/extract.go | 14 +++ pkg/repos/get.go | 43 ++++++-- pkg/repos/runtimes/busybox/SHASUMS256.txt | 1 + pkg/repos/runtimes/busybox/busybox.go | 107 ++++++++++++++++++++ pkg/repos/runtimes/busybox/busybox_test.go | 41 ++++++++ pkg/repos/runtimes/busybox/log.go | 5 + pkg/repos/runtimes/default.go | 2 + pkg/tests/runner_test.go | 3 - pkg/tests/testdata/TestContextArg/other.gpt | 4 +- pkg/tests/testdata/TestContextArg/test.gpt | 2 +- pkg/tests/tester/runner.go | 12 ++- 14 files changed, 243 insertions(+), 32 deletions(-) create mode 100644 pkg/repos/runtimes/busybox/SHASUMS256.txt create mode 100644 pkg/repos/runtimes/busybox/busybox.go create mode 100644 pkg/repos/runtimes/busybox/busybox_test.go create mode 100644 pkg/repos/runtimes/busybox/log.go diff --git a/go.mod b/go.mod index 783e638b..c84dae97 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc golang.org/x/sync v0.7.0 - golang.org/x/term v0.20.0 + golang.org/x/term v0.22.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.1 sigs.k8s.io/yaml v1.4.0 @@ -108,10 +108,10 @@ require ( github.com/yuin/goldmark v1.5.4 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect go4.org v0.0.0-20200411211856-f5505b9728dd // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.24.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect - golang.org/x/tools v0.20.0 // indirect + golang.org/x/mod v0.19.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/tools v0.23.0 // indirect mvdan.cc/gofumpt v0.6.0 // indirect ) diff --git a/go.sum b/go.sum index fd165d3f..7e2d7b75 100644 --- a/go.sum +++ b/go.sum @@ -396,8 +396,8 @@ golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -419,8 +419,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -474,8 +474,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -485,8 +485,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -498,8 +498,8 @@ golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -531,8 +531,8 @@ golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= -golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= -golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/engine/cmd.go b/pkg/engine/cmd.go index d62aad2e..8e7b234e 100644 --- a/pkg/engine/cmd.go +++ b/pkg/engine/cmd.go @@ -10,6 +10,7 @@ import ( "io" "os" "os/exec" + "path" "path/filepath" "runtime" "sort" @@ -269,6 +270,10 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T }) } + if runtime.GOOS == "windows" && (args[0] == "/bin/bash" || args[0] == "/bin/sh") { + args[0] = path.Base(args[0]) + } + if runtime.GOOS == "windows" && (args[0] == "/usr/bin/env" || args[0] == "/bin/env") { args = args[1:] } diff --git a/pkg/repos/download/extract.go b/pkg/repos/download/extract.go index 95a82d74..4cf09f0c 100644 --- a/pkg/repos/download/extract.go +++ b/pkg/repos/download/extract.go @@ -9,7 +9,9 @@ import ( "net/http" "net/url" "os" + "path" "path/filepath" + "strings" "time" "github.com/mholt/archiver/v4" @@ -60,6 +62,18 @@ func Extract(ctx context.Context, downloadURL, digest, targetDir string) error { return err } + bin := path.Base(parsedURL.Path) + if strings.HasSuffix(bin, ".exe") { + dst, err := os.Create(filepath.Join(targetDir, bin)) + if err != nil { + return err + } + defer dst.Close() + + _, err = io.Copy(dst, tmpFile) + return err + } + format, input, err := archiver.Identify(filepath.Base(parsedURL.Path), tmpFile) if err != nil { return err diff --git a/pkg/repos/get.go b/pkg/repos/get.go index 2f96d8b3..bead4a7a 100644 --- a/pkg/repos/get.go +++ b/pkg/repos/get.go @@ -15,6 +15,7 @@ import ( "github.com/BurntSushi/locker" "github.com/gptscript-ai/gptscript/pkg/config" "github.com/gptscript-ai/gptscript/pkg/credentials" + "github.com/gptscript-ai/gptscript/pkg/hash" "github.com/gptscript-ai/gptscript/pkg/loader/github" "github.com/gptscript-ai/gptscript/pkg/repos/git" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes/golang" @@ -51,6 +52,7 @@ type Manager struct { credHelperDirs credentials.CredentialHelperDirs runtimes []Runtime credHelperConfig *credHelperConfig + supportLocal bool } type credHelperConfig struct { @@ -60,6 +62,10 @@ type credHelperConfig struct { env []string } +func (m *Manager) SetSupportLocal() { + m.supportLocal = true +} + func New(cacheDir string, runtimes ...Runtime) *Manager { root := filepath.Join(cacheDir, "repos") return &Manager{ @@ -200,8 +206,14 @@ func (m *Manager) setup(ctx context.Context, runtime Runtime, tool types.Tool, e _ = os.RemoveAll(doneFile) _ = os.RemoveAll(target) - if err := git.Checkout(ctx, m.gitDir, tool.Source.Repo.Root, tool.Source.Repo.Revision, target); err != nil { - return "", nil, err + if tool.Source.Repo.VCS == "git" { + if err := git.Checkout(ctx, m.gitDir, tool.Source.Repo.Root, tool.Source.Repo.Revision, target); err != nil { + return "", nil, err + } + } else { + if err := os.MkdirAll(target, 0755); err != nil { + return "", nil, err + } } newEnv, err := runtime.Setup(ctx, m.runtimeDir, targetFinal, env) @@ -227,12 +239,25 @@ func (m *Manager) setup(ctx context.Context, runtime Runtime, tool types.Tool, e } func (m *Manager) GetContext(ctx context.Context, tool types.Tool, cmd, env []string) (string, []string, error) { - if tool.Source.Repo == nil { - return tool.WorkingDir, env, nil - } + var isLocal bool + if !m.supportLocal { + if tool.Source.Repo == nil { + return tool.WorkingDir, env, nil + } - if tool.Source.Repo.VCS != "git" { - return "", nil, fmt.Errorf("only git is supported, found VCS %s for %s", tool.Source.Repo.VCS, tool.ID) + if tool.Source.Repo.VCS != "git" { + return "", nil, fmt.Errorf("only git is supported, found VCS %s for %s", tool.Source.Repo.VCS, tool.ID) + } + } else if tool.Source.Repo == nil { + isLocal = true + id := hash.Digest(tool)[:12] + tool.Source.Repo = &types.Repo{ + VCS: "", + Root: id, + Path: "/", + Name: id, + Revision: id, + } } for _, runtime := range m.runtimes { @@ -242,5 +267,9 @@ func (m *Manager) GetContext(ctx context.Context, tool types.Tool, cmd, env []st } } + if isLocal { + return tool.WorkingDir, env, nil + } + return m.setup(ctx, &noopRuntime{}, tool, env) } diff --git a/pkg/repos/runtimes/busybox/SHASUMS256.txt b/pkg/repos/runtimes/busybox/SHASUMS256.txt new file mode 100644 index 00000000..7da1aff6 --- /dev/null +++ b/pkg/repos/runtimes/busybox/SHASUMS256.txt @@ -0,0 +1 @@ +6d2dfd1c1412c3550a89071a1b36a6f6073844320e687343d1dfc72719ecb8d9 FRP-5301-gda71f7c57/busybox-w64-FRP-5301-gda71f7c57.exe \ No newline at end of file diff --git a/pkg/repos/runtimes/busybox/busybox.go b/pkg/repos/runtimes/busybox/busybox.go new file mode 100644 index 00000000..b0c00a0c --- /dev/null +++ b/pkg/repos/runtimes/busybox/busybox.go @@ -0,0 +1,107 @@ +package busybox + +import ( + "bufio" + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strings" + + runtimeEnv "github.com/gptscript-ai/gptscript/pkg/env" + "github.com/gptscript-ai/gptscript/pkg/hash" + "github.com/gptscript-ai/gptscript/pkg/repos/download" +) + +//go:embed SHASUMS256.txt +var releasesData []byte + +const downloadURL = "https://github.com/gptscript-ai/busybox-w32/releases/download/%s" + +type Runtime struct { +} + +func (r *Runtime) ID() string { + return "busybox" +} + +func (r *Runtime) Supports(cmd []string) bool { + if runtime.GOOS != "windows" { + return false + } + for _, bin := range []string{"bash", "sh", "/bin/sh", "/bin/bash"} { + if runtimeEnv.Matches(cmd, bin) { + return true + } + } + return false +} + +func (r *Runtime) Setup(ctx context.Context, dataRoot, _ string, env []string) ([]string, error) { + binPath, err := r.getRuntime(ctx, dataRoot) + if err != nil { + return nil, err + } + + newEnv := runtimeEnv.AppendPath(env, binPath) + return newEnv, nil +} + +func (r *Runtime) getReleaseAndDigest() (string, string, error) { + scanner := bufio.NewScanner(bytes.NewReader(releasesData)) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + return fmt.Sprintf(downloadURL, fields[1]), fields[0], nil + } + + return "", "", fmt.Errorf("failed to find %s release", r.ID()) +} + +func (r *Runtime) getRuntime(ctx context.Context, cwd string) (string, error) { + url, sha, err := r.getReleaseAndDigest() + if err != nil { + return "", err + } + + target := filepath.Join(cwd, "busybox", hash.ID(url, sha)) + if _, err := os.Stat(target); err == nil { + return target, nil + } else if !errors.Is(err, fs.ErrNotExist) { + return "", err + } + + log.Infof("Downloading Busybox") + tmp := target + ".download" + defer os.RemoveAll(tmp) + + if err := os.MkdirAll(tmp, 0755); err != nil { + return "", err + } + + if err := download.Extract(ctx, url, sha, tmp); err != nil { + return "", err + } + + bbExe := filepath.Join(tmp, path.Base(url)) + + cmd := exec.Command(bbExe, "--install", ".") + cmd.Dir = filepath.Dir(bbExe) + + if err := cmd.Run(); err != nil { + return "", err + } + + if err := os.Rename(tmp, target); err != nil { + return "", err + } + + return target, nil +} diff --git a/pkg/repos/runtimes/busybox/busybox_test.go b/pkg/repos/runtimes/busybox/busybox_test.go new file mode 100644 index 00000000..f3add18a --- /dev/null +++ b/pkg/repos/runtimes/busybox/busybox_test.go @@ -0,0 +1,41 @@ +package busybox + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/adrg/xdg" + "github.com/samber/lo" + "github.com/stretchr/testify/require" +) + +var ( + testCacheHome = lo.Must(xdg.CacheFile("gptscript-test-cache/runtime")) +) + +func firstPath(s []string) string { + _, p, _ := strings.Cut(s[0], "=") + return strings.Split(p, string(os.PathListSeparator))[0] +} + +func TestRuntime(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip() + } + + r := Runtime{} + + s, err := r.Setup(context.Background(), testCacheHome, "testdata", os.Environ()) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(firstPath(s), "busybox.exe")) + if errors.Is(err, fs.ErrNotExist) { + _, err = os.Stat(filepath.Join(firstPath(s), "busybox")) + } + require.NoError(t, err) +} diff --git a/pkg/repos/runtimes/busybox/log.go b/pkg/repos/runtimes/busybox/log.go new file mode 100644 index 00000000..b7e486f1 --- /dev/null +++ b/pkg/repos/runtimes/busybox/log.go @@ -0,0 +1,5 @@ +package busybox + +import "github.com/gptscript-ai/gptscript/pkg/mvl" + +var log = mvl.Package() diff --git a/pkg/repos/runtimes/default.go b/pkg/repos/runtimes/default.go index d37cca8f..3782e26e 100644 --- a/pkg/repos/runtimes/default.go +++ b/pkg/repos/runtimes/default.go @@ -3,12 +3,14 @@ package runtimes import ( "github.com/gptscript-ai/gptscript/pkg/engine" "github.com/gptscript-ai/gptscript/pkg/repos" + "github.com/gptscript-ai/gptscript/pkg/repos/runtimes/busybox" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes/golang" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes/node" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes/python" ) var Runtimes = []repos.Runtime{ + &busybox.Runtime{}, &python.Runtime{ Version: "3.12", Default: true, diff --git a/pkg/tests/runner_test.go b/pkg/tests/runner_test.go index 12eff23a..60f1cfc3 100644 --- a/pkg/tests/runner_test.go +++ b/pkg/tests/runner_test.go @@ -751,9 +751,6 @@ func TestGlobalErr(t *testing.T) { } func TestContextArg(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip() - } runner := tester.NewRunner(t) x, err := runner.Run("", `{ "file": "foo.db" diff --git a/pkg/tests/testdata/TestContextArg/other.gpt b/pkg/tests/testdata/TestContextArg/other.gpt index b1acd66a..f97b4ba6 100644 --- a/pkg/tests/testdata/TestContextArg/other.gpt +++ b/pkg/tests/testdata/TestContextArg/other.gpt @@ -2,5 +2,5 @@ name: fromcontext args: first: an arg args: second: an arg -#!/bin/bash -echo this is from other context ${first} and then ${second} \ No newline at end of file +#!/usr/bin/env bash +echo this is from other context ${FIRST} and then ${SECOND} \ No newline at end of file diff --git a/pkg/tests/testdata/TestContextArg/test.gpt b/pkg/tests/testdata/TestContextArg/test.gpt index 9569aaf9..50d2ccf2 100644 --- a/pkg/tests/testdata/TestContextArg/test.gpt +++ b/pkg/tests/testdata/TestContextArg/test.gpt @@ -9,4 +9,4 @@ name: fromcontext args: first: an arg #!/bin/bash -echo this is from context -- ${first} \ No newline at end of file +echo this is from context -- ${FIRST} \ No newline at end of file diff --git a/pkg/tests/tester/runner.go b/pkg/tests/tester/runner.go index 775f0248..ef75c0f5 100644 --- a/pkg/tests/tester/runner.go +++ b/pkg/tests/tester/runner.go @@ -8,8 +8,11 @@ import ( "path/filepath" "testing" + "github.com/adrg/xdg" "github.com/gptscript-ai/gptscript/pkg/credentials" "github.com/gptscript-ai/gptscript/pkg/loader" + "github.com/gptscript-ai/gptscript/pkg/repos" + "github.com/gptscript-ai/gptscript/pkg/repos/runtimes" "github.com/gptscript-ai/gptscript/pkg/runner" "github.com/gptscript-ai/gptscript/pkg/types" "github.com/hexops/autogold/v2" @@ -171,8 +174,15 @@ func NewRunner(t *testing.T) *Runner { t: t, } + cacheDir, err := xdg.CacheFile("gptscript-test-cache/runtime") + require.NoError(t, err) + + rm := runtimes.Default(cacheDir) + rm.(*repos.Manager).SetSupportLocal() + run, err := runner.New(c, credentials.NoopStore{}, runner.Options{ - Sequential: true, + Sequential: true, + RuntimeManager: rm, }) require.NoError(t, err) From 4c3da3a352b5a5f26553685fb07938529d7ce766 Mon Sep 17 00:00:00 2001 From: Taylor Price Date: Fri, 2 Aug 2024 16:02:37 -0700 Subject: [PATCH 03/21] chore: uppercase env variable names Signed-off-by: Taylor Price --- integration/scripts/cred_scopes.gpt | 4 ++-- pkg/engine/cmd.go | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/integration/scripts/cred_scopes.gpt b/integration/scripts/cred_scopes.gpt index 7319f163..dc8e24e7 100644 --- a/integration/scripts/cred_scopes.gpt +++ b/integration/scripts/cred_scopes.gpt @@ -149,8 +149,8 @@ name: getcred import os import json -var = os.getenv('var') -val = os.getenv('val') +var = os.getenv('VAR') +val = os.getenv('VAL') output = { "env": { diff --git a/pkg/engine/cmd.go b/pkg/engine/cmd.go index 8e7b234e..869e67dc 100644 --- a/pkg/engine/cmd.go +++ b/pkg/engine/cmd.go @@ -204,10 +204,9 @@ var ignoreENV = map[string]struct{}{ } func appendEnv(envs []string, k, v string) []string { - for _, k := range []string{k, env.ToEnvLike(k)} { - if _, ignore := ignoreENV[k]; !ignore { - envs = append(envs, k+"="+v) - } + //fmt.Printf("%s=%s\n", k, v) + if _, ignore := ignoreENV[k]; !ignore { + envs = append(envs, strings.ToUpper(k)+"="+v) } return envs } From 9b8dc494ac29a921ea274b12814b2117514b655f Mon Sep 17 00:00:00 2001 From: Taylor Price Date: Mon, 5 Aug 2024 10:05:35 -0700 Subject: [PATCH 04/21] fix: correct small comment typo --- pkg/tests/smoke/smoke_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tests/smoke/smoke_test.go b/pkg/tests/smoke/smoke_test.go index 66374ef1..a6d0ab2c 100644 --- a/pkg/tests/smoke/smoke_test.go +++ b/pkg/tests/smoke/smoke_test.go @@ -83,7 +83,7 @@ func TestSmoke(t *testing.T) { actualEvents, ` - disregard differences in timestamps, generated IDs, natural language verbiage, and event order -- omit callProgress events from the comparision +- omit callProgress events from the comparison - the overall stream of events and set of tools called should roughly match - arguments passed in tool calls should be roughly the same - the final callFinish event should be semantically similar From ae328ac395ecfc710399a5b50fbc0aaa799c4168 Mon Sep 17 00:00:00 2001 From: Taylor Price Date: Mon, 5 Aug 2024 14:05:16 -0700 Subject: [PATCH 05/21] chore: ensure key is env-like Signed-off-by: Taylor Price --- pkg/engine/cmd.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/engine/cmd.go b/pkg/engine/cmd.go index 869e67dc..484b00cb 100644 --- a/pkg/engine/cmd.go +++ b/pkg/engine/cmd.go @@ -204,9 +204,8 @@ var ignoreENV = map[string]struct{}{ } func appendEnv(envs []string, k, v string) []string { - //fmt.Printf("%s=%s\n", k, v) if _, ignore := ignoreENV[k]; !ignore { - envs = append(envs, strings.ToUpper(k)+"="+v) + envs = append(envs, strings.ToUpper(env.ToEnvLike(k))+"="+v) } return envs } From 806fdc38b48658cb5bb68cb492f411f4fc1d55c0 Mon Sep 17 00:00:00 2001 From: Darren Shepherd Date: Mon, 5 Aug 2024 20:56:56 -0700 Subject: [PATCH 06/21] feat: support inline package.json and requirements.txt --- pkg/engine/cmd.go | 2 +- pkg/env/env.go | 9 ++ pkg/parser/parser.go | 40 +++++- pkg/parser/parser_test.go | 29 ++++ pkg/repos/get.go | 30 ++-- pkg/repos/runtimes/busybox/busybox.go | 5 +- pkg/repos/runtimes/busybox/busybox_test.go | 3 +- pkg/repos/runtimes/golang/golang.go | 8 +- pkg/repos/runtimes/golang/golang_test.go | 3 +- pkg/repos/runtimes/node/node.go | 26 +++- pkg/repos/runtimes/node/node_test.go | 5 +- pkg/repos/runtimes/python/python.go | 29 +++- pkg/repos/runtimes/python/python_test.go | 3 +- pkg/tests/runner_test.go | 21 +++ .../testdata/TestRuntimes/call1-resp.golden | 16 +++ pkg/tests/testdata/TestRuntimes/call1.golden | 37 +++++ .../testdata/TestRuntimes/call2-resp.golden | 16 +++ pkg/tests/testdata/TestRuntimes/call2.golden | 70 +++++++++ .../testdata/TestRuntimes/call3-resp.golden | 16 +++ pkg/tests/testdata/TestRuntimes/call3.golden | 103 +++++++++++++ .../testdata/TestRuntimes/call4-resp.golden | 9 ++ pkg/tests/testdata/TestRuntimes/call4.golden | 136 ++++++++++++++++++ pkg/tests/testdata/TestRuntimes/test.gpt | 58 ++++++++ pkg/tests/tester/runner.go | 2 - pkg/types/tool.go | 5 + 25 files changed, 635 insertions(+), 46 deletions(-) create mode 100644 pkg/tests/testdata/TestRuntimes/call1-resp.golden create mode 100644 pkg/tests/testdata/TestRuntimes/call1.golden create mode 100644 pkg/tests/testdata/TestRuntimes/call2-resp.golden create mode 100644 pkg/tests/testdata/TestRuntimes/call2.golden create mode 100644 pkg/tests/testdata/TestRuntimes/call3-resp.golden create mode 100644 pkg/tests/testdata/TestRuntimes/call3.golden create mode 100644 pkg/tests/testdata/TestRuntimes/call4-resp.golden create mode 100644 pkg/tests/testdata/TestRuntimes/call4.golden create mode 100644 pkg/tests/testdata/TestRuntimes/test.gpt diff --git a/pkg/engine/cmd.go b/pkg/engine/cmd.go index 484b00cb..57dfd477 100644 --- a/pkg/engine/cmd.go +++ b/pkg/engine/cmd.go @@ -282,7 +282,7 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T ) if strings.TrimSpace(rest) != "" { - f, err := os.CreateTemp("", version.ProgramName+requiredFileExtensions[args[0]]) + f, err := os.CreateTemp(env.Getenv("GPTSCRIPT_TMPDIR", envvars), version.ProgramName+requiredFileExtensions[args[0]]) if err != nil { return nil, nil, err } diff --git a/pkg/env/env.go b/pkg/env/env.go index bedd5f9d..2994825b 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -26,6 +26,15 @@ func ToEnvLike(v string) string { return strings.ToUpper(v) } +func Getenv(key string, envs []string) string { + for i := len(envs) - 1; i >= 0; i-- { + if k, v, ok := strings.Cut(envs[i], "="); ok && k == key { + return v + } + } + return "" +} + func Matches(cmd []string, bin string) bool { switch len(cmd) { case 0: diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index ff5d1374..b57cb658 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -16,7 +16,7 @@ import ( var ( sepRegex = regexp.MustCompile(`^\s*---+\s*$`) strictSepRegex = regexp.MustCompile(`^---\n$`) - skipRegex = regexp.MustCompile(`^![-\w]+\s*$`) + skipRegex = regexp.MustCompile(`^![-.:\w]+\s*$`) ) func normalize(key string) string { @@ -308,6 +308,8 @@ func Parse(input io.Reader, opts ...Options) (Document, error) { } } + nodes = assignMetadata(nodes) + if !opt.AssignGlobals { return Document{ Nodes: nodes, @@ -359,6 +361,42 @@ func Parse(input io.Reader, opts ...Options) (Document, error) { }, nil } +func assignMetadata(nodes []Node) (result []Node) { + metadata := map[string]map[string]string{} + result = make([]Node, 0, len(nodes)) + for _, node := range nodes { + if node.TextNode != nil { + body, ok := strings.CutPrefix(node.TextNode.Text, "!metadata:") + if ok { + line, rest, ok := strings.Cut(body, "\n") + if ok { + toolName, metaKey, ok := strings.Cut(strings.TrimSpace(line), ":") + if ok { + d, ok := metadata[toolName] + if !ok { + d = map[string]string{} + metadata[toolName] = d + } + d[metaKey] = strings.TrimSpace(rest) + } + } + } + } + } + if len(metadata) == 0 { + return nodes + } + + for _, node := range nodes { + if node.ToolNode != nil { + node.ToolNode.Tool.MetaData = metadata[node.ToolNode.Tool.Name] + } + result = append(result, node) + } + + return +} + func isGPTScriptHashBang(line string) bool { if !strings.HasPrefix(line, "#!") { return false diff --git a/pkg/parser/parser_test.go b/pkg/parser/parser_test.go index 9f682efa..3967ebd5 100644 --- a/pkg/parser/parser_test.go +++ b/pkg/parser/parser_test.go @@ -6,6 +6,7 @@ import ( "github.com/gptscript-ai/gptscript/pkg/types" "github.com/hexops/autogold/v2" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -239,3 +240,31 @@ share output filters: shared }}, }}).Equal(t, out) } + +func TestParseMetaData(t *testing.T) { + input := ` +name: first + +body +--- +!metadata:first:package.json +foo=base +f + +--- +!metadata:first2:requirements.txt +asdf2 + +--- +!metadata:first:requirements.txt +asdf +` + tools, err := ParseTools(strings.NewReader(input)) + require.NoError(t, err) + + assert.Len(t, tools, 1) + autogold.Expect(map[string]string{ + "package.json": "foo=base\nf", + "requirements.txt": "asdf", + }).Equal(t, tools[0].MetaData) +} diff --git a/pkg/repos/get.go b/pkg/repos/get.go index bead4a7a..fc675c58 100644 --- a/pkg/repos/get.go +++ b/pkg/repos/get.go @@ -26,8 +26,8 @@ const credentialHelpersRepo = "github.com/gptscript-ai/gptscript-credential-help type Runtime interface { ID() string - Supports(cmd []string) bool - Setup(ctx context.Context, dataRoot, toolSource string, env []string) ([]string, error) + Supports(tool types.Tool, cmd []string) bool + Setup(ctx context.Context, tool types.Tool, dataRoot, toolSource string, env []string) ([]string, error) } type noopRuntime struct { @@ -37,11 +37,11 @@ func (n noopRuntime) ID() string { return "none" } -func (n noopRuntime) Supports(_ []string) bool { +func (n noopRuntime) Supports(_ types.Tool, _ []string) bool { return false } -func (n noopRuntime) Setup(_ context.Context, _, _ string, _ []string) ([]string, error) { +func (n noopRuntime) Setup(_ context.Context, _ types.Tool, _, _ string, _ []string) ([]string, error) { return nil, nil } @@ -52,7 +52,6 @@ type Manager struct { credHelperDirs credentials.CredentialHelperDirs runtimes []Runtime credHelperConfig *credHelperConfig - supportLocal bool } type credHelperConfig struct { @@ -62,10 +61,6 @@ type credHelperConfig struct { env []string } -func (m *Manager) SetSupportLocal() { - m.supportLocal = true -} - func New(cacheDir string, runtimes ...Runtime) *Manager { root := filepath.Join(cacheDir, "repos") return &Manager{ @@ -216,7 +211,7 @@ func (m *Manager) setup(ctx context.Context, runtime Runtime, tool types.Tool, e } } - newEnv, err := runtime.Setup(ctx, m.runtimeDir, targetFinal, env) + newEnv, err := runtime.Setup(ctx, tool, m.runtimeDir, targetFinal, env) if err != nil { return "", nil, err } @@ -240,17 +235,10 @@ func (m *Manager) setup(ctx context.Context, runtime Runtime, tool types.Tool, e func (m *Manager) GetContext(ctx context.Context, tool types.Tool, cmd, env []string) (string, []string, error) { var isLocal bool - if !m.supportLocal { - if tool.Source.Repo == nil { - return tool.WorkingDir, env, nil - } - - if tool.Source.Repo.VCS != "git" { - return "", nil, fmt.Errorf("only git is supported, found VCS %s for %s", tool.Source.Repo.VCS, tool.ID) - } - } else if tool.Source.Repo == nil { + if tool.Source.Repo == nil { isLocal = true - id := hash.Digest(tool)[:12] + d, _ := json.Marshal(tool) + id := hash.Digest(d)[:12] tool.Source.Repo = &types.Repo{ VCS: "", Root: id, @@ -261,7 +249,7 @@ func (m *Manager) GetContext(ctx context.Context, tool types.Tool, cmd, env []st } for _, runtime := range m.runtimes { - if runtime.Supports(cmd) { + if runtime.Supports(tool, cmd) { log.Debugf("Runtime %s supports %v", runtime.ID(), cmd) return m.setup(ctx, runtime, tool, env) } diff --git a/pkg/repos/runtimes/busybox/busybox.go b/pkg/repos/runtimes/busybox/busybox.go index b0c00a0c..542ba94a 100644 --- a/pkg/repos/runtimes/busybox/busybox.go +++ b/pkg/repos/runtimes/busybox/busybox.go @@ -18,6 +18,7 @@ import ( runtimeEnv "github.com/gptscript-ai/gptscript/pkg/env" "github.com/gptscript-ai/gptscript/pkg/hash" "github.com/gptscript-ai/gptscript/pkg/repos/download" + "github.com/gptscript-ai/gptscript/pkg/types" ) //go:embed SHASUMS256.txt @@ -32,7 +33,7 @@ func (r *Runtime) ID() string { return "busybox" } -func (r *Runtime) Supports(cmd []string) bool { +func (r *Runtime) Supports(_ types.Tool, cmd []string) bool { if runtime.GOOS != "windows" { return false } @@ -44,7 +45,7 @@ func (r *Runtime) Supports(cmd []string) bool { return false } -func (r *Runtime) Setup(ctx context.Context, dataRoot, _ string, env []string) ([]string, error) { +func (r *Runtime) Setup(ctx context.Context, _ types.Tool, dataRoot, _ string, env []string) ([]string, error) { binPath, err := r.getRuntime(ctx, dataRoot) if err != nil { return nil, err diff --git a/pkg/repos/runtimes/busybox/busybox_test.go b/pkg/repos/runtimes/busybox/busybox_test.go index f3add18a..77bfae59 100644 --- a/pkg/repos/runtimes/busybox/busybox_test.go +++ b/pkg/repos/runtimes/busybox/busybox_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/adrg/xdg" + "github.com/gptscript-ai/gptscript/pkg/types" "github.com/samber/lo" "github.com/stretchr/testify/require" ) @@ -31,7 +32,7 @@ func TestRuntime(t *testing.T) { r := Runtime{} - s, err := r.Setup(context.Background(), testCacheHome, "testdata", os.Environ()) + s, err := r.Setup(context.Background(), types.Tool{}, testCacheHome, "testdata", os.Environ()) require.NoError(t, err) _, err = os.Stat(filepath.Join(firstPath(s), "busybox.exe")) if errors.Is(err, fs.ErrNotExist) { diff --git a/pkg/repos/runtimes/golang/golang.go b/pkg/repos/runtimes/golang/golang.go index 28300439..b19cfe90 100644 --- a/pkg/repos/runtimes/golang/golang.go +++ b/pkg/repos/runtimes/golang/golang.go @@ -18,6 +18,7 @@ import ( runtimeEnv "github.com/gptscript-ai/gptscript/pkg/env" "github.com/gptscript-ai/gptscript/pkg/hash" "github.com/gptscript-ai/gptscript/pkg/repos/download" + "github.com/gptscript-ai/gptscript/pkg/types" ) //go:embed digests.txt @@ -34,11 +35,12 @@ func (r *Runtime) ID() string { return "go" + r.Version } -func (r *Runtime) Supports(cmd []string) bool { - return len(cmd) > 0 && cmd[0] == "${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool" +func (r *Runtime) Supports(tool types.Tool, cmd []string) bool { + return tool.Source.IsGit() && + len(cmd) > 0 && cmd[0] == "${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool" } -func (r *Runtime) Setup(ctx context.Context, dataRoot, toolSource string, env []string) ([]string, error) { +func (r *Runtime) Setup(ctx context.Context, _ types.Tool, dataRoot, toolSource string, env []string) ([]string, error) { binPath, err := r.getRuntime(ctx, dataRoot) if err != nil { return nil, err diff --git a/pkg/repos/runtimes/golang/golang_test.go b/pkg/repos/runtimes/golang/golang_test.go index 5f71fb50..56098a51 100644 --- a/pkg/repos/runtimes/golang/golang_test.go +++ b/pkg/repos/runtimes/golang/golang_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/adrg/xdg" + "github.com/gptscript-ai/gptscript/pkg/types" "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -27,7 +28,7 @@ func TestRuntime(t *testing.T) { Version: "1.22.1", } - s, err := r.Setup(context.Background(), testCacheHome, "testdata", os.Environ()) + s, err := r.Setup(context.Background(), types.Tool{}, testCacheHome, "testdata", os.Environ()) require.NoError(t, err) p, v, _ := strings.Cut(s[0], "=") v, _, _ = strings.Cut(v, string(filepath.ListSeparator)) diff --git a/pkg/repos/runtimes/node/node.go b/pkg/repos/runtimes/node/node.go index 575e3b23..fde5103d 100644 --- a/pkg/repos/runtimes/node/node.go +++ b/pkg/repos/runtimes/node/node.go @@ -17,12 +17,16 @@ import ( runtimeEnv "github.com/gptscript-ai/gptscript/pkg/env" "github.com/gptscript-ai/gptscript/pkg/hash" "github.com/gptscript-ai/gptscript/pkg/repos/download" + "github.com/gptscript-ai/gptscript/pkg/types" ) //go:embed SHASUMS256.txt.asc var releasesData []byte -const downloadURL = "https://nodejs.org/dist/%s/" +const ( + downloadURL = "https://nodejs.org/dist/%s/" + packageJSON = "package.json" +) type Runtime struct { // version something like "3.12" @@ -35,7 +39,10 @@ func (r *Runtime) ID() string { return "node" + r.Version } -func (r *Runtime) Supports(cmd []string) bool { +func (r *Runtime) Supports(tool types.Tool, cmd []string) bool { + if _, hasPackageJSON := tool.MetaData[packageJSON]; !hasPackageJSON && !tool.Source.IsGit() { + return false + } for _, testCmd := range []string{"node", "npx", "npm"} { if r.supports(testCmd, cmd) { return true @@ -54,17 +61,21 @@ func (r *Runtime) supports(testCmd string, cmd []string) bool { return runtimeEnv.Matches(cmd, testCmd) } -func (r *Runtime) Setup(ctx context.Context, dataRoot, toolSource string, env []string) ([]string, error) { +func (r *Runtime) Setup(ctx context.Context, tool types.Tool, dataRoot, toolSource string, env []string) ([]string, error) { binPath, err := r.getRuntime(ctx, dataRoot) if err != nil { return nil, err } newEnv := runtimeEnv.AppendPath(env, binPath) - if err := r.runNPM(ctx, toolSource, binPath, append(env, newEnv...)); err != nil { + if err := r.runNPM(ctx, tool, toolSource, binPath, append(env, newEnv...)); err != nil { return nil, err } + if _, ok := tool.MetaData[packageJSON]; ok { + newEnv = append(newEnv, "GPTSCRIPT_TMPDIR="+toolSource) + } + return newEnv, nil } @@ -100,11 +111,16 @@ func (r *Runtime) getReleaseAndDigest() (string, string, error) { return "", "", fmt.Errorf("failed to find %s release for os=%s arch=%s", r.ID(), osName(), arch()) } -func (r *Runtime) runNPM(ctx context.Context, toolSource, binDir string, env []string) error { +func (r *Runtime) runNPM(ctx context.Context, tool types.Tool, toolSource, binDir string, env []string) error { log.InfofCtx(ctx, "Running npm in %s", toolSource) cmd := debugcmd.New(ctx, filepath.Join(binDir, "npm"), "install") cmd.Env = env cmd.Dir = toolSource + if contents, ok := tool.MetaData[packageJSON]; ok { + if err := os.WriteFile(filepath.Join(toolSource, packageJSON), []byte(contents+"\n"), 0644); err != nil { + return err + } + } return cmd.Run() } diff --git a/pkg/repos/runtimes/node/node_test.go b/pkg/repos/runtimes/node/node_test.go index 50ef1e0a..014619c8 100644 --- a/pkg/repos/runtimes/node/node_test.go +++ b/pkg/repos/runtimes/node/node_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/adrg/xdg" + "github.com/gptscript-ai/gptscript/pkg/types" "github.com/samber/lo" "github.com/stretchr/testify/require" ) @@ -28,7 +29,7 @@ func TestRuntime(t *testing.T) { Version: "20", } - s, err := r.Setup(context.Background(), testCacheHome, "testdata", os.Environ()) + s, err := r.Setup(context.Background(), types.Tool{}, testCacheHome, "testdata", os.Environ()) require.NoError(t, err) _, err = os.Stat(filepath.Join(firstPath(s), "node.exe")) if errors.Is(err, fs.ErrNotExist) { @@ -42,7 +43,7 @@ func TestRuntime21(t *testing.T) { Version: "21", } - s, err := r.Setup(context.Background(), testCacheHome, "testdata", os.Environ()) + s, err := r.Setup(context.Background(), types.Tool{}, testCacheHome, "testdata", os.Environ()) require.NoError(t, err) _, err = os.Stat(filepath.Join(firstPath(s), "node.exe")) if errors.Is(err, fs.ErrNotExist) { diff --git a/pkg/repos/runtimes/python/python.go b/pkg/repos/runtimes/python/python.go index c031cb16..ae24f92a 100644 --- a/pkg/repos/runtimes/python/python.go +++ b/pkg/repos/runtimes/python/python.go @@ -17,12 +17,16 @@ import ( runtimeEnv "github.com/gptscript-ai/gptscript/pkg/env" "github.com/gptscript-ai/gptscript/pkg/hash" "github.com/gptscript-ai/gptscript/pkg/repos/download" + "github.com/gptscript-ai/gptscript/pkg/types" ) //go:embed python.json var releasesData []byte -const uvVersion = "uv==0.2.27" +const ( + uvVersion = "uv==0.2.33" + requirementsTxt = "requirements.txt" +) type Release struct { OS string `json:"os,omitempty"` @@ -43,7 +47,10 @@ func (r *Runtime) ID() string { return "python" + r.Version } -func (r *Runtime) Supports(cmd []string) bool { +func (r *Runtime) Supports(tool types.Tool, cmd []string) bool { + if _, hasRequirements := tool.MetaData[requirementsTxt]; !hasRequirements && !tool.Source.IsGit() { + return false + } if runtimeEnv.Matches(cmd, r.ID()) { return true } @@ -112,7 +119,7 @@ func (r *Runtime) copyPythonForWindows(binDir string) error { return nil } -func (r *Runtime) Setup(ctx context.Context, dataRoot, toolSource string, env []string) ([]string, error) { +func (r *Runtime) Setup(ctx context.Context, tool types.Tool, dataRoot, toolSource string, env []string) ([]string, error) { binPath, err := r.getRuntime(ctx, dataRoot) if err != nil { return nil, err @@ -145,7 +152,7 @@ func (r *Runtime) Setup(ctx context.Context, dataRoot, toolSource string, env [] } } - if err := r.runPip(ctx, toolSource, binPath, append(env, newEnv...)); err != nil { + if err := r.runPip(ctx, tool, toolSource, binPath, append(env, newEnv...)); err != nil { return nil, err } @@ -170,9 +177,19 @@ func (r *Runtime) getReleaseAndDigest() (string, string, error) { return "", "", fmt.Errorf("failed to find an python runtime for %s", r.Version) } -func (r *Runtime) runPip(ctx context.Context, toolSource, binDir string, env []string) error { +func (r *Runtime) runPip(ctx context.Context, tool types.Tool, toolSource, binDir string, env []string) error { log.InfofCtx(ctx, "Running pip in %s", toolSource) - for _, req := range []string{"requirements-gptscript.txt", "requirements.txt"} { + if content, ok := tool.MetaData[requirementsTxt]; ok { + reqFile := filepath.Join(toolSource, requirementsTxt) + if err := os.WriteFile(reqFile, []byte(content+"\n"), 0644); err != nil { + return err + } + cmd := debugcmd.New(ctx, uvBin(binDir), "pip", "install", "-r", reqFile) + cmd.Env = env + return cmd.Run() + } + + for _, req := range []string{"requirements-gptscript.txt", requirementsTxt} { reqFile := filepath.Join(toolSource, req) if s, err := os.Stat(reqFile); err == nil && !s.IsDir() { cmd := debugcmd.New(ctx, uvBin(binDir), "pip", "install", "-r", reqFile) diff --git a/pkg/repos/runtimes/python/python_test.go b/pkg/repos/runtimes/python/python_test.go index fc2ededc..0e483305 100644 --- a/pkg/repos/runtimes/python/python_test.go +++ b/pkg/repos/runtimes/python/python_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/adrg/xdg" + "github.com/gptscript-ai/gptscript/pkg/types" "github.com/samber/lo" "github.com/stretchr/testify/require" ) @@ -27,7 +28,7 @@ func TestRuntime(t *testing.T) { Version: "3.12", } - s, err := r.Setup(context.Background(), testCacheHome, "testdata", os.Environ()) + s, err := r.Setup(context.Background(), types.Tool{}, testCacheHome, "testdata", os.Environ()) require.NoError(t, err) _, err = os.Stat(filepath.Join(firstPath(s), "python.exe")) if errors.Is(err, os.ErrNotExist) { diff --git a/pkg/tests/runner_test.go b/pkg/tests/runner_test.go index 60f1cfc3..424c84c1 100644 --- a/pkg/tests/runner_test.go +++ b/pkg/tests/runner_test.go @@ -997,3 +997,24 @@ func TestToolRefAll(t *testing.T) { r := tester.NewRunner(t) r.RunDefault() } + +func TestRuntimes(t *testing.T) { + r := tester.NewRunner(t) + r.RespondWith(tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "py", + Arguments: "{}", + }, + }, tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "node", + Arguments: "{}", + }, + }, tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "bash", + Arguments: "{}", + }, + }) + r.RunDefault() +} diff --git a/pkg/tests/testdata/TestRuntimes/call1-resp.golden b/pkg/tests/testdata/TestRuntimes/call1-resp.golden new file mode 100644 index 00000000..1d53670a --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call1-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimes/call1.golden b/pkg/tests/testdata/TestRuntimes/call1.golden new file mode 100644 index 00000000..67c7d9f7 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call1.golden @@ -0,0 +1,37 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimes/call2-resp.golden b/pkg/tests/testdata/TestRuntimes/call2-resp.golden new file mode 100644 index 00000000..4806793c --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call2-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimes/call2.golden b/pkg/tests/testdata/TestRuntimes/call2.golden new file mode 100644 index 00000000..da456a5d --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call2.golden @@ -0,0 +1,70 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimes/call3-resp.golden b/pkg/tests/testdata/TestRuntimes/call3-resp.golden new file mode 100644 index 00000000..1103f824 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call3-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimes/call3.golden b/pkg/tests/testdata/TestRuntimes/call3.golden new file mode 100644 index 00000000..f0792540 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call3.golden @@ -0,0 +1,103 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "node worked\n" + } + ], + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimes/call4-resp.golden b/pkg/tests/testdata/TestRuntimes/call4-resp.golden new file mode 100644 index 00000000..8135a8c9 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call4-resp.golden @@ -0,0 +1,9 @@ +`{ + "role": "assistant", + "content": [ + { + "text": "TEST RESULT CALL: 4" + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimes/call4.golden b/pkg/tests/testdata/TestRuntimes/call4.golden new file mode 100644 index 00000000..04ac31f5 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/call4.golden @@ -0,0 +1,136 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimes/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "node worked\n" + } + ], + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "bash works\n" + } + ], + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimes/test.gpt b/pkg/tests/testdata/TestRuntimes/test.gpt new file mode 100644 index 00000000..db3ede64 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimes/test.gpt @@ -0,0 +1,58 @@ +name: first +tools: py, node, bash + +Dummy + +--- +name: py + +#!/usr/bin/env python3 + +import requests +import platform + +# this is dumb hack to get the line endings to always be \r\n so the golden files match +# on both linux and windows +if platform.system() == 'Windows': + print('py worked') +else: + print('py worked\r') + +--- +!metadata:py:requirements.txt + +requests + +--- +name: node + +#!/usr/bin/env node + +import chalk from 'chalk'; +console.log("node worked") + +--- +!metadata:node:package.json + +{ + "name": "chalk-example", + "version": "1.0.0", + "type": "module", + "description": "A simple example project to demonstrate the use of chalk", + "main": "example.js", + "scripts": { + "start": "node example.js" + }, + "author": "Your Name", + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0" + } + } + +--- +name: bash + +#!/bin/bash + +echo bash works \ No newline at end of file diff --git a/pkg/tests/tester/runner.go b/pkg/tests/tester/runner.go index ef75c0f5..a36c5e91 100644 --- a/pkg/tests/tester/runner.go +++ b/pkg/tests/tester/runner.go @@ -11,7 +11,6 @@ import ( "github.com/adrg/xdg" "github.com/gptscript-ai/gptscript/pkg/credentials" "github.com/gptscript-ai/gptscript/pkg/loader" - "github.com/gptscript-ai/gptscript/pkg/repos" "github.com/gptscript-ai/gptscript/pkg/repos/runtimes" "github.com/gptscript-ai/gptscript/pkg/runner" "github.com/gptscript-ai/gptscript/pkg/types" @@ -178,7 +177,6 @@ func NewRunner(t *testing.T) *Runner { require.NoError(t, err) rm := runtimes.Default(cacheDir) - rm.(*repos.Manager).SetSupportLocal() run, err := runner.New(c, credentials.NoopStore{}, runner.Options{ Sequential: true, diff --git a/pkg/types/tool.go b/pkg/types/tool.go index 54d5d817..61a67fa4 100644 --- a/pkg/types/tool.go +++ b/pkg/types/tool.go @@ -166,6 +166,7 @@ type Tool struct { ID string `json:"id,omitempty"` ToolMapping map[string][]ToolReference `json:"toolMapping,omitempty"` + MetaData map[string]string `json:"metaData,omitempty"` LocalTools map[string]string `json:"localTools,omitempty"` Source ToolSource `json:"source,omitempty"` WorkingDir string `json:"workingDir,omitempty"` @@ -793,6 +794,10 @@ type ToolSource struct { Repo *Repo `json:"repo,omitempty"` } +func (t ToolSource) IsGit() bool { + return t.Repo != nil && t.Repo.VCS == "git" +} + func (t ToolSource) String() string { return fmt.Sprintf("%s:%d", t.Location, t.LineNo) } From d8b1ea856423ce9f8b8b8e98f039a24c0ea24b79 Mon Sep 17 00:00:00 2001 From: Taylor Price Date: Tue, 6 Aug 2024 13:34:30 -0700 Subject: [PATCH 07/21] fix: windows absolute path logic --- pkg/loader/loader.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/loader/loader.go b/pkg/loader/loader.go index 3d2ae8ed..1dfaa0e2 100644 --- a/pkg/loader/loader.go +++ b/pkg/loader/loader.go @@ -68,10 +68,8 @@ func openFile(path string) (io.ReadCloser, bool, error) { } func loadLocal(base *source, name string) (*source, bool, error) { - // We want to keep all strings in / format, and only convert to platform specific when reading - // This is why we use path instead of filepath. filePath := name - if !path.IsAbs(name) { + if !filepath.IsAbs(name) { filePath = path.Join(base.Path, name) } From 4fd8e8a2ad24f48e48f50c65368488c1ea48046b Mon Sep 17 00:00:00 2001 From: Taylor Price Date: Tue, 6 Aug 2024 15:28:08 -0700 Subject: [PATCH 08/21] fix: add comment back in correct place, right above the place we mutate path Signed-off-by: Taylor Price --- pkg/loader/loader.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/loader/loader.go b/pkg/loader/loader.go index 1dfaa0e2..80342f2b 100644 --- a/pkg/loader/loader.go +++ b/pkg/loader/loader.go @@ -70,6 +70,8 @@ func openFile(path string) (io.ReadCloser, bool, error) { func loadLocal(base *source, name string) (*source, bool, error) { filePath := name if !filepath.IsAbs(name) { + // We want to keep all strings in / format, and only convert to platform specific when reading + // This is why we use path instead of filepath. filePath = path.Join(base.Path, name) } From c0507a2c32dd543a43e46526710fab12b81c04f8 Mon Sep 17 00:00:00 2001 From: Donnie Adams Date: Tue, 6 Aug 2024 21:02:19 -0400 Subject: [PATCH 09/21] feat: allow providers to be restarted if they stop By not caching the client, gptscript is able to restart the provider daemon if it stops. If the daemon is still running, then there is little overhead because the daemon URL is cached and the tool will not be completely reprocessed. The model to provider mapping is still cached so that the client can be recreated when necessary. Signed-off-by: Donnie Adams --- pkg/remote/remote.go | 52 +++++++++++++++----------------------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/pkg/remote/remote.go b/pkg/remote/remote.go index 6d83e6cc..89863529 100644 --- a/pkg/remote/remote.go +++ b/pkg/remote/remote.go @@ -22,10 +22,9 @@ import ( ) type Client struct { - clientsLock sync.Mutex + modelsLock sync.Mutex cache *cache.Client - clients map[string]*openai.Client - models map[string]*openai.Client + modelToProvider map[string]string runner *runner.Runner envs []string credStore credentials.CredentialStore @@ -43,14 +42,19 @@ func New(r *runner.Runner, envs []string, cache *cache.Client, credStore credent } func (c *Client) Call(ctx context.Context, messageRequest types.CompletionRequest, status chan<- types.CompletionStatus) (*types.CompletionMessage, error) { - c.clientsLock.Lock() - client, ok := c.models[messageRequest.Model] - c.clientsLock.Unlock() + c.modelsLock.Lock() + provider, ok := c.modelToProvider[messageRequest.Model] + c.modelsLock.Unlock() if !ok { return nil, fmt.Errorf("failed to find remote model %s", messageRequest.Model) } + client, err := c.load(ctx, provider) + if err != nil { + return nil, err + } + toolName, modelName := types.SplitToolRef(messageRequest.Model) if modelName == "" { // modelName is empty, then the messageRequest.Model is not of the form 'modelName from provider' @@ -96,19 +100,19 @@ func (c *Client) Supports(ctx context.Context, modelString string) (bool, error) return false, nil } - client, err := c.load(ctx, providerName) + _, err := c.load(ctx, providerName) if err != nil { return false, err } - c.clientsLock.Lock() - defer c.clientsLock.Unlock() + c.modelsLock.Lock() + defer c.modelsLock.Unlock() - if c.models == nil { - c.models = map[string]*openai.Client{} + if c.modelToProvider == nil { + c.modelToProvider = map[string]string{} } - c.models[modelString] = client + c.modelToProvider[modelString] = providerName return true, nil } @@ -141,24 +145,11 @@ func (c *Client) clientFromURL(ctx context.Context, apiURL string) (*openai.Clie } func (c *Client) load(ctx context.Context, toolName string) (*openai.Client, error) { - c.clientsLock.Lock() - defer c.clientsLock.Unlock() - - client, ok := c.clients[toolName] - if ok { - return client, nil - } - - if c.clients == nil { - c.clients = make(map[string]*openai.Client) - } - if isHTTPURL(toolName) { remoteClient, err := c.clientFromURL(ctx, toolName) if err != nil { return nil, err } - c.clients[toolName] = remoteClient return remoteClient, nil } @@ -174,14 +165,8 @@ func (c *Client) load(ctx context.Context, toolName string) (*openai.Client, err return nil, err } - if strings.HasSuffix(url, "/") { - url += "v1" - } else { - url += "/v1" - } - - client, err = openai.NewClient(ctx, c.credStore, openai.Options{ - BaseURL: url, + client, err := openai.NewClient(ctx, c.credStore, openai.Options{ + BaseURL: strings.TrimSuffix(url, "/") + "/v1", Cache: c.cache, CacheKey: prg.EntryToolID, }) @@ -189,7 +174,6 @@ func (c *Client) load(ctx context.Context, toolName string) (*openai.Client, err return nil, err } - c.clients[toolName] = client return client, nil } From 8fc57e02c84adf3a1f6e68cd9d7bfd7d9023023a Mon Sep 17 00:00:00 2001 From: Donnie Adams Date: Wed, 7 Aug 2024 11:19:58 -0400 Subject: [PATCH 10/21] feat: allow disabling of the cache when parsing tools Signed-off-by: Donnie Adams --- Makefile | 6 ++++-- pkg/cli/gptscript.go | 2 +- pkg/cli/parse.go | 3 ++- pkg/input/input.go | 4 ++-- pkg/loader/url.go | 6 ++++-- pkg/sdkserver/routes.go | 2 +- pkg/sdkserver/types.go | 3 ++- 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index e0d93a1d..5b1b6309 100644 --- a/Makefile +++ b/Makefile @@ -52,12 +52,14 @@ init-docs: docker run --rm --workdir=/docs -v $${PWD}/docs:/docs node:18-buster yarn install # Ensure docs build without errors. Makes sure generated docs are in-sync with CLI. -validate-docs: +validate-docs: gen-docs docker run --rm --workdir=/docs -v $${PWD}/docs:/docs node:18-buster yarn build - go run tools/gendocs/main.go if [ -n "$$(git status --porcelain --untracked-files=no)" ]; then \ git status --porcelain --untracked-files=no; \ echo "Encountered dirty repo!"; \ git diff; \ exit 1 \ ;fi + +gen-docs: + go run tools/gendocs/main.go \ No newline at end of file diff --git a/pkg/cli/gptscript.go b/pkg/cli/gptscript.go index 4458d87b..536e339b 100644 --- a/pkg/cli/gptscript.go +++ b/pkg/cli/gptscript.go @@ -83,7 +83,7 @@ func New() *cobra.Command { root, &Eval{gptscript: root}, &Credential{root: root}, - &Parse{}, + &Parse{gptscript: root}, &Fmt{}, &Getenv{}, &SDKServer{ diff --git a/pkg/cli/parse.go b/pkg/cli/parse.go index 8599bd97..081116c1 100644 --- a/pkg/cli/parse.go +++ b/pkg/cli/parse.go @@ -12,6 +12,7 @@ import ( type Parse struct { PrettyPrint bool `usage:"Indent the json output" short:"p"` + gptscript *GPTScript } func (e *Parse) Customize(cmd *cobra.Command) { @@ -26,7 +27,7 @@ func locationName(l string) string { } func (e *Parse) Run(_ *cobra.Command, args []string) error { - content, err := input.FromLocation(args[0]) + content, err := input.FromLocation(args[0], e.gptscript.DisableCache) if err != nil { return err } diff --git a/pkg/input/input.go b/pkg/input/input.go index 3d480431..f930753f 100644 --- a/pkg/input/input.go +++ b/pkg/input/input.go @@ -55,13 +55,13 @@ func FromFile(file string) (string, error) { } // FromLocation takes a string that can be a file path or a URL to a file and returns the content of that file. -func FromLocation(s string) (string, error) { +func FromLocation(s string, disableCache bool) (string, error) { // Attempt to read the file first, if that fails, try to load the URL. Finally, // return an error if both fail. content, err := FromFile(s) if err != nil { log.Debugf("failed to read file %s (due to %v) attempting to load the URL...", s, err) - content, err = loader.ContentFromURL(s) + content, err = loader.ContentFromURL(s, disableCache) if err != nil { return "", err } diff --git a/pkg/loader/url.go b/pkg/loader/url.go index 41400790..72970546 100644 --- a/pkg/loader/url.go +++ b/pkg/loader/url.go @@ -207,8 +207,10 @@ func getWithDefaults(req *http.Request) ([]byte, string, error) { panic("unreachable") } -func ContentFromURL(url string) (string, error) { - cache, err := cache.New() +func ContentFromURL(url string, disableCache bool) (string, error) { + cache, err := cache.New(cache.Options{ + DisableCache: disableCache, + }) if err != nil { return "", fmt.Errorf("failed to create cache: %w", err) } diff --git a/pkg/sdkserver/routes.go b/pkg/sdkserver/routes.go index 2e709e3f..c0d7a41b 100644 --- a/pkg/sdkserver/routes.go +++ b/pkg/sdkserver/routes.go @@ -227,7 +227,7 @@ func (s *server) parse(w http.ResponseWriter, r *http.Request) { if reqObject.Content != "" { out, err = parser.Parse(strings.NewReader(reqObject.Content), reqObject.Options) } else { - content, loadErr := input.FromLocation(reqObject.File) + content, loadErr := input.FromLocation(reqObject.File, reqObject.DisableCache) if loadErr != nil { logger.Errorf(loadErr.Error()) writeError(logger, w, http.StatusInternalServerError, loadErr) diff --git a/pkg/sdkserver/types.go b/pkg/sdkserver/types.go index 9736f045..ade035b2 100644 --- a/pkg/sdkserver/types.go +++ b/pkg/sdkserver/types.go @@ -86,7 +86,8 @@ type parseRequest struct { parser.Options `json:",inline"` content `json:",inline"` - File string `json:"file"` + DisableCache bool `json:"disableCache"` + File string `json:"file"` } type modelsRequest struct { From dc4dcfce04772d5dae360b5602d3670e9cc5217d Mon Sep 17 00:00:00 2001 From: Grant Linville Date: Wed, 7 Aug 2024 12:37:51 -0400 Subject: [PATCH 11/21] fix: always set GPTSCRIPT_CREDENTIAL_EXPIRATION env var when credentials are used (#727) Signed-off-by: Grant Linville --- pkg/runner/runner.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index c2137bea..a8d88fee 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -968,19 +968,19 @@ func (r *Runner) handleCredentials(callCtx engine.Context, monitor Monitor, env } else { log.Warnf("Not saving credential for tool %s - credentials will only be saved for tools from GitHub, or tools that use aliases.", toolName) } + } - if c.ExpiresAt != nil && (nearestExpiration == nil || nearestExpiration.After(*c.ExpiresAt)) { - nearestExpiration = c.ExpiresAt - } + if c.ExpiresAt != nil && (nearestExpiration == nil || nearestExpiration.After(*c.ExpiresAt)) { + nearestExpiration = c.ExpiresAt } for k, v := range c.Env { env = append(env, fmt.Sprintf("%s=%s", k, v)) } + } - if nearestExpiration != nil { - env = append(env, fmt.Sprintf("%s=%s", credentials.CredentialExpiration, nearestExpiration.Format(time.RFC3339))) - } + if nearestExpiration != nil { + env = append(env, fmt.Sprintf("%s=%s", credentials.CredentialExpiration, nearestExpiration.Format(time.RFC3339))) } return env, nil From 40cc9785f3e19e7998e3f4fa001c246372917064 Mon Sep 17 00:00:00 2001 From: Darren Shepherd Date: Wed, 7 Aug 2024 09:43:54 -0700 Subject: [PATCH 12/21] bug: change quoting behavior When reading the #! interpreter line variables in quotes "${FOO}" will be evaluated and passed as a single argument to the command. Unquoted variables like ${FOO} may result in multiple argument. For example, if FOO="Hello World" that will result in two arguments "Hello" and "World", where "${FOO}" will result in one argument "Hello World" --- pkg/engine/cmd.go | 93 ++++++++++++++++++++++++++++ pkg/engine/cmd_test.go | 135 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 pkg/engine/cmd_test.go diff --git a/pkg/engine/cmd.go b/pkg/engine/cmd.go index 57dfd477..311c743a 100644 --- a/pkg/engine/cmd.go +++ b/pkg/engine/cmd.go @@ -123,6 +123,9 @@ func (e *Engine) runCommand(ctx Context, tool types.Tool, input string, toolCate } cmd, stop, err := e.newCommand(ctx.Ctx, extraEnv, tool, input) if err != nil { + if toolCategory == NoCategory { + return fmt.Sprintf("ERROR: got (%v) while parsing command", err), nil + } return "", err } defer stop() @@ -268,6 +271,12 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T }) } + // After we determined the interpreter we again interpret the args by env vars + args, err = replaceVariablesForInterpreter(interpreter, envMap) + if err != nil { + return nil, nil, err + } + if runtime.GOOS == "windows" && (args[0] == "/bin/bash" || args[0] == "/bin/sh") { args[0] = path.Base(args[0]) } @@ -314,3 +323,87 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T cmd.Env = compressEnv(envvars) return cmd, stop, nil } + +func replaceVariablesForInterpreter(interpreter string, envMap map[string]string) ([]string, error) { + var parts []string + for i, part := range splitByQuotes(interpreter) { + if i%2 == 0 { + part = os.Expand(part, func(s string) string { + return envMap[s] + }) + // We protect newly resolved env vars from getting replaced when we do the second Expand + // after shlex. Yeah, crazy. I'm guessing this isn't secure, but just trying to avoid a foot gun. + part = os.Expand(part, func(s string) string { + return "${__" + s + "}" + }) + } + parts = append(parts, part) + } + + parts, err := shlex.Split(strings.Join(parts, "")) + if err != nil { + return nil, err + } + + for i, part := range parts { + parts[i] = os.Expand(part, func(s string) string { + if strings.HasPrefix(s, "__") { + return "${" + s[2:] + "}" + } + return envMap[s] + }) + } + + return parts, nil +} + +// splitByQuotes will split a string by parsing matching double quotes (with \ as the escape character). +// The return value conforms to the following properties +// 1. s == strings.Join(result, "") +// 2. Even indexes are strings that were not in quotes. +// 3. Odd indexes are strings that were quoted. +// +// Example: s = `In a "quoted string" quotes can be escaped with \"` +// +// result = [`In a `, `"quoted string"`, ` quotes can be escaped with \"`] +func splitByQuotes(s string) (result []string) { + var ( + buf strings.Builder + inEscape, inQuote bool + ) + + for _, c := range s { + if inEscape { + buf.WriteRune(c) + inEscape = false + continue + } + + switch c { + case '"': + if inQuote { + buf.WriteRune(c) + } + result = append(result, buf.String()) + buf.Reset() + if !inQuote { + buf.WriteRune(c) + } + inQuote = !inQuote + case '\\': + inEscape = true + buf.WriteRune(c) + default: + buf.WriteRune(c) + } + } + + if buf.Len() > 0 { + if inQuote { + result = append(result, "") + } + result = append(result, buf.String()) + } + + return +} diff --git a/pkg/engine/cmd_test.go b/pkg/engine/cmd_test.go new file mode 100644 index 00000000..15f72036 --- /dev/null +++ b/pkg/engine/cmd_test.go @@ -0,0 +1,135 @@ +// File: cmd_test.go +package engine + +import "testing" + +func TestSplitByQuotes(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "NoQuotes", + input: "Hello World", + expected: []string{"Hello World"}, + }, + { + name: "ValidQuote", + input: `"Hello" "World"`, + expected: []string{``, `"Hello"`, ` `, `"World"`}, + }, + { + name: "ValidQuoteWithEscape", + input: `"Hello\" World"`, + expected: []string{``, `"Hello\" World"`}, + }, + { + name: "Nothing", + input: "", + expected: []string{}, + }, + { + name: "SpaceInsideQuote", + input: `"Hello World"`, + expected: []string{``, `"Hello World"`}, + }, + { + name: "SingleChar", + input: "H", + expected: []string{"H"}, + }, + { + name: "SingleQuote", + input: `"Hello`, + expected: []string{``, ``, `"Hello`}, + }, + { + name: "ThreeQuotes", + input: `Test "Hello "World" End\"`, + expected: []string{`Test `, `"Hello "`, `World`, ``, `" End\"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitByQuotes(tt.input) + if !equal(got, tt.expected) { + t.Errorf("splitByQuotes() = %v, want %v", got, tt.expected) + } + }) + } +} + +// Helper function to assert equality of two string slices. +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i, v := range a { + if v != b[i] { + return false + } + } + return true +} + +// Testing for replaceVariablesForInterpreter +func TestReplaceVariablesForInterpreter(t *testing.T) { + tests := []struct { + name string + interpreter string + envMap map[string]string + expected []string + shouldFail bool + }{ + { + name: "No quotes", + interpreter: "/bin/bash -c ${COMMAND} tail", + envMap: map[string]string{"COMMAND": "echo Hello!"}, + expected: []string{"/bin/bash", "-c", "echo", "Hello!", "tail"}, + }, + { + name: "Quotes Variables", + interpreter: `/bin/bash -c "${COMMAND}" tail`, + envMap: map[string]string{"COMMAND": "Hello, World!"}, + expected: []string{"/bin/bash", "-c", "Hello, World!", "tail"}, + }, + { + name: "Double escape", + interpreter: `/bin/bash -c "${COMMAND}" ${TWO} tail`, + envMap: map[string]string{ + "COMMAND": "Hello, World!", + "TWO": "${COMMAND}", + }, + expected: []string{"/bin/bash", "-c", "Hello, World!", "${COMMAND}", "tail"}, + }, + { + name: "aws cli issue", + interpreter: "aws ${ARGS}", + envMap: map[string]string{ + "ARGS": `ec2 describe-instances --region us-east-1 --query 'Reservations[*].Instances[*].{Instance:InstanceId,State:State.Name}'`, + }, + expected: []string{ + `aws`, + `ec2`, + `describe-instances`, + `--region`, `us-east-1`, + `--query`, `Reservations[*].Instances[*].{Instance:InstanceId,State:State.Name}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := replaceVariablesForInterpreter(tt.interpreter, tt.envMap) + if (err != nil) != tt.shouldFail { + t.Errorf("replaceVariablesForInterpreter() error = %v, want %v", err, tt.shouldFail) + return + } + if !equal(got, tt.expected) { + t.Errorf("replaceVariablesForInterpreter() = %v, want %v", got, tt.expected) + } + }) + } +} From ab1768a2177282a1a9b4bd4f2b62066ce42c2087 Mon Sep 17 00:00:00 2001 From: Daishan Peng Date: Wed, 7 Aug 2024 11:20:38 -0700 Subject: [PATCH 13/21] Fix: Fix query not added if there are multple entries Signed-off-by: Daishan Peng --- pkg/openapi/run.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/openapi/run.go b/pkg/openapi/run.go index 17199851..2efc2309 100644 --- a/pkg/openapi/run.go +++ b/pkg/openapi/run.go @@ -177,6 +177,7 @@ func HandleAuths(req *http.Request, envMap map[string]string, infoSets [][]Secur // We're using this info set, because no environment variables were missing. // Set up the request as needed. + v := url.Values{} for _, info := range infoSet { envNames := maps.Values(info.getCredentialNamesAndEnvVars(req.URL.Hostname())) switch info.Type { @@ -185,9 +186,7 @@ func HandleAuths(req *http.Request, envMap map[string]string, infoSets [][]Secur case "header": req.Header.Set(info.APIKeyName, envMap[envNames[0]]) case "query": - v := url.Values{} v.Add(info.APIKeyName, envMap[envNames[0]]) - req.URL.RawQuery = v.Encode() case "cookie": req.AddCookie(&http.Cookie{ Name: info.APIKeyName, @@ -203,6 +202,9 @@ func HandleAuths(req *http.Request, envMap map[string]string, infoSets [][]Secur } } } + if len(v) > 0 { + req.URL.RawQuery = v.Encode() + } return nil } From bb2340bd53e483c7cb9c2ae2b8f6c4919a7fcd5c Mon Sep 17 00:00:00 2001 From: Donnie Adams Date: Wed, 7 Aug 2024 16:14:24 -0400 Subject: [PATCH 14/21] fix: make vars capital in smoke tests A recent change in gptscript got rid of the lowercase env vars because they don't work on Windows. This change updates the smoke tests. Signed-off-by: Donnie Adams --- pkg/tests/smoke/testdata/Bob/test.gpt | 2 +- pkg/tests/smoke/testdata/BobAsShell/test.gpt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/tests/smoke/testdata/Bob/test.gpt b/pkg/tests/smoke/testdata/Bob/test.gpt index fe8ffb62..20f533e2 100644 --- a/pkg/tests/smoke/testdata/Bob/test.gpt +++ b/pkg/tests/smoke/testdata/Bob/test.gpt @@ -7,4 +7,4 @@ name: bob description: I'm Bob, a friendly guy. args: question: The question to ask Bob. -When asked how I am doing, respond with exactly "Thanks for asking "${question}", I'm doing great fellow friendly AI tool!" +When asked how I am doing, respond with exactly "Thanks for asking "${QUESTION}", I'm doing great fellow friendly AI tool!" diff --git a/pkg/tests/smoke/testdata/BobAsShell/test.gpt b/pkg/tests/smoke/testdata/BobAsShell/test.gpt index f04920bf..a0edb9c4 100644 --- a/pkg/tests/smoke/testdata/BobAsShell/test.gpt +++ b/pkg/tests/smoke/testdata/BobAsShell/test.gpt @@ -10,4 +10,4 @@ args: question: The question to ask Bob. #!/bin/bash -echo "Thanks for asking ${question}, I'm doing great fellow friendly AI tool!" +echo "Thanks for asking ${QUESTION}, I'm doing great fellow friendly AI tool!" From 04f42cdc4b1aacf82241eb62b1e545bba014269c Mon Sep 17 00:00:00 2001 From: Nick Hale <4175918+njhale@users.noreply.github.com> Date: Wed, 7 Aug 2024 23:25:33 -0400 Subject: [PATCH 15/21] chore: pin ui tool version to v0.9.4 Signed-off-by: Nick Hale <4175918+njhale@users.noreply.github.com> --- pkg/cli/gptscript.go | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/pkg/cli/gptscript.go b/pkg/cli/gptscript.go index 536e339b..d7221715 100644 --- a/pkg/cli/gptscript.go +++ b/pkg/cli/gptscript.go @@ -386,7 +386,9 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) { // The UI must run in daemon mode. r.Daemon = true // Use the UI tool as the first argument. - args = append([]string{uiTool()}, args...) + args = append([]string{ + env.VarOrDefault("GPTSCRIPT_CHAT_UI_TOOL", "github.com/gptscript-ai/ui@v0.9.4"), + }, args...) } ctx := cmd.Context() @@ -503,15 +505,3 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) { return r.PrintOutput(toolInput, s) } - -// uiTool returns the versioned UI tool reference for the current GPTScript version. -// For release versions, a reference with a matching release tag is returned. -// For all other versions, a reference to main is returned. -func uiTool() string { - ref := "github.com/gptscript-ai/ui" - if tag := version.Tag; !strings.Contains(tag, "v0.0.0-dev") { - ref = fmt.Sprintf("%s@%s", ref, tag) - } - - return env.VarOrDefault("GPTSCRIPT_CHAT_UI_TOOL", ref) -} From 26476d27a34513b123301ba382e07372d1bc6a18 Mon Sep 17 00:00:00 2001 From: Darren Shepherd Date: Thu, 8 Aug 2024 12:43:50 -0700 Subject: [PATCH 16/21] chore: move metadata field to tooldef struct --- pkg/types/tool.go | 21 ++++++++++++++++++--- pkg/types/tool_test.go | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/pkg/types/tool.go b/pkg/types/tool.go index 61a67fa4..bb49e6f1 100644 --- a/pkg/types/tool.go +++ b/pkg/types/tool.go @@ -157,8 +157,9 @@ func (p Parameters) ToolRefNames() []string { type ToolDef struct { Parameters `json:",inline"` - Instructions string `json:"instructions,omitempty"` - BuiltinFunc BuiltinFunc `json:"-"` + Instructions string `json:"instructions,omitempty"` + BuiltinFunc BuiltinFunc `json:"-"` + MetaData map[string]string `json:"metaData,omitempty"` } type Tool struct { @@ -166,7 +167,6 @@ type Tool struct { ID string `json:"id,omitempty"` ToolMapping map[string][]ToolReference `json:"toolMapping,omitempty"` - MetaData map[string]string `json:"metaData,omitempty"` LocalTools map[string]string `json:"localTools,omitempty"` Source ToolSource `json:"source,omitempty"` WorkingDir string `json:"workingDir,omitempty"` @@ -489,6 +489,21 @@ func (t ToolDef) String() string { _, _ = fmt.Fprintln(buf, t.Instructions) } + if t.Name != "" { + keys := maps.Keys(t.MetaData) + sort.Strings(keys) + for _, key := range keys { + buf.WriteString("---\n") + buf.WriteString("!metadata:") + buf.WriteString(t.Name) + buf.WriteString(":") + buf.WriteString(key) + buf.WriteString("\n") + buf.WriteString(t.MetaData[key]) + buf.WriteString("\n") + } + } + return buf.String() } diff --git a/pkg/types/tool_test.go b/pkg/types/tool_test.go index a47014a1..e95c2248 100644 --- a/pkg/types/tool_test.go +++ b/pkg/types/tool_test.go @@ -36,6 +36,13 @@ func TestToolDef_String(t *testing.T) { ExportCredentials: []string{"ExportCredential1", "ExportCredential2"}, Type: ToolTypeContext, }, + MetaData: map[string]string{ + "package.json": `{ +// blah blah some ugly JSON +} +`, + "requirements.txt": `requests=5`, + }, Instructions: "This is a sample instruction", } @@ -68,6 +75,15 @@ Share Credential: ExportCredential2 Chat: true This is a sample instruction +--- +!metadata:Tool Sample:package.json +{ +// blah blah some ugly JSON +} + +--- +!metadata:Tool Sample:requirements.txt +requests=5 `).Equal(t, tool.String()) } From 227d8530ad3a4490145a61eea9eeeefb6043f625 Mon Sep 17 00:00:00 2001 From: Nick Hale <4175918+njhale@users.noreply.github.com> Date: Thu, 8 Aug 2024 21:13:25 -0400 Subject: [PATCH 17/21] chore: remove ui dispatch from release workflow The UI must be updated independently from now on for electron releases to work properly. Signed-off-by: Nick Hale <4175918+njhale@users.noreply.github.com> --- .github/workflows/release.yaml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4c1c22bf..f710e953 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,13 +12,6 @@ jobs: release-tag: runs-on: ubuntu-22.04 steps: - - name: trigger ui repo tag workflow - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.DISPATCH_PAT }} - repository: gptscript-ai/ui - event-type: release - client-payload: '{"tag": "${{ github.ref_name }}"}' - name: Checkout uses: actions/checkout@v4 with: From 2e51afe0ac4ee2875c7693c1d8d5c439e29abf59 Mon Sep 17 00:00:00 2001 From: John Engelman Date: Mon, 24 Jun 2024 13:57:26 +0530 Subject: [PATCH 18/21] feat: Add support for tools from github enterprise. --- .../04-command-line-reference/gptscript.md | 65 +++++++------- pkg/cli/gptscript.go | 44 ++++++---- pkg/loader/github/github.go | 87 ++++++++++++++----- pkg/loader/github/github_test.go | 57 ++++++++++++ 4 files changed, 178 insertions(+), 75 deletions(-) diff --git a/docs/docs/04-command-line-reference/gptscript.md b/docs/docs/04-command-line-reference/gptscript.md index de29a97f..b7de5e86 100644 --- a/docs/docs/04-command-line-reference/gptscript.md +++ b/docs/docs/04-command-line-reference/gptscript.md @@ -12,38 +12,39 @@ gptscript [flags] PROGRAM_FILE [INPUT...] ### Options ``` - --cache-dir string Directory to store cache (default: $XDG_CACHE_HOME/gptscript) ($GPTSCRIPT_CACHE_DIR) - --chat-state string The chat state to continue, or null to start a new chat and return the state ($GPTSCRIPT_CHAT_STATE) - -C, --chdir string Change current working directory ($GPTSCRIPT_CHDIR) - --color Use color in output (default true) ($GPTSCRIPT_COLOR) - --config string Path to GPTScript config file ($GPTSCRIPT_CONFIG) - --confirm Prompt before running potentially dangerous commands ($GPTSCRIPT_CONFIRM) - --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") - --credential-override strings Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234) ($GPTSCRIPT_CREDENTIAL_OVERRIDE) - --debug Enable debug logging ($GPTSCRIPT_DEBUG) - --debug-messages Enable logging of chat completion calls ($GPTSCRIPT_DEBUG_MESSAGES) - --default-model string Default LLM model to use ($GPTSCRIPT_DEFAULT_MODEL) (default "gpt-4o") - --default-model-provider string Default LLM model provider to use, this will override OpenAI settings ($GPTSCRIPT_DEFAULT_MODEL_PROVIDER) - --disable-cache Disable caching of LLM API responses ($GPTSCRIPT_DISABLE_CACHE) - --disable-tui Don't use chat TUI but instead verbose output ($GPTSCRIPT_DISABLE_TUI) - --dump-state string Dump the internal execution state to a file ($GPTSCRIPT_DUMP_STATE) - --events-stream-to string Stream events to this location, could be a file descriptor/handle (e.g. fd://2), filename, or named pipe (e.g. \\.\pipe\my-pipe) ($GPTSCRIPT_EVENTS_STREAM_TO) - --force-chat Force an interactive chat session if even the top level tool is not a chat tool ($GPTSCRIPT_FORCE_CHAT) - --force-sequential Force parallel calls to run sequentially ($GPTSCRIPT_FORCE_SEQUENTIAL) - -h, --help help for gptscript - -f, --input string Read input from a file ("-" for stdin) ($GPTSCRIPT_INPUT_FILE) - --list-models List the models available and exit ($GPTSCRIPT_LIST_MODELS) - --list-tools List built-in tools and exit ($GPTSCRIPT_LIST_TOOLS) - --no-trunc Do not truncate long log messages ($GPTSCRIPT_NO_TRUNC) - --openai-api-key string OpenAI API KEY ($OPENAI_API_KEY) - --openai-base-url string OpenAI base URL ($OPENAI_BASE_URL) - --openai-org-id string OpenAI organization ID ($OPENAI_ORG_ID) - -o, --output string Save output to a file, or - for stdout ($GPTSCRIPT_OUTPUT) - -q, --quiet No output logging (set --quiet=false to force on even when there is no TTY) ($GPTSCRIPT_QUIET) - --save-chat-state-file string A file to save the chat state to so that a conversation can be resumed with --chat-state ($GPTSCRIPT_SAVE_CHAT_STATE_FILE) - --sub-tool string Use tool of this name, not the first tool in file ($GPTSCRIPT_SUB_TOOL) - --ui Launch the UI ($GPTSCRIPT_UI) - --workspace string Directory to use for the workspace, if specified it will not be deleted on exit ($GPTSCRIPT_WORKSPACE) + --cache-dir string Directory to store cache (default: $XDG_CACHE_HOME/gptscript) ($GPTSCRIPT_CACHE_DIR) + --chat-state string The chat state to continue, or null to start a new chat and return the state ($GPTSCRIPT_CHAT_STATE) + -C, --chdir string Change current working directory ($GPTSCRIPT_CHDIR) + --color Use color in output (default true) ($GPTSCRIPT_COLOR) + --config string Path to GPTScript config file ($GPTSCRIPT_CONFIG) + --confirm Prompt before running potentially dangerous commands ($GPTSCRIPT_CONFIRM) + --credential-context string Context name in which to store credentials ($GPTSCRIPT_CREDENTIAL_CONTEXT) (default "default") + --credential-override strings Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234) ($GPTSCRIPT_CREDENTIAL_OVERRIDE) + --debug Enable debug logging ($GPTSCRIPT_DEBUG) + --debug-messages Enable logging of chat completion calls ($GPTSCRIPT_DEBUG_MESSAGES) + --default-model string Default LLM model to use ($GPTSCRIPT_DEFAULT_MODEL) (default "gpt-4o") + --default-model-provider string Default LLM model provider to use, this will override OpenAI settings ($GPTSCRIPT_DEFAULT_MODEL_PROVIDER) + --disable-cache Disable caching of LLM API responses ($GPTSCRIPT_DISABLE_CACHE) + --disable-tui Don't use chat TUI but instead verbose output ($GPTSCRIPT_DISABLE_TUI) + --dump-state string Dump the internal execution state to a file ($GPTSCRIPT_DUMP_STATE) + --events-stream-to string Stream events to this location, could be a file descriptor/handle (e.g. fd://2), filename, or named pipe (e.g. \\.\pipe\my-pipe) ($GPTSCRIPT_EVENTS_STREAM_TO) + --force-chat Force an interactive chat session if even the top level tool is not a chat tool ($GPTSCRIPT_FORCE_CHAT) + --force-sequential Force parallel calls to run sequentially ($GPTSCRIPT_FORCE_SEQUENTIAL) + --github-enterprise-hostname string The host name for a Github Enterprise instance to enable for remote loading ($GPTSCRIPT_GITHUB_ENTERPRISE_HOSTNAME) + -h, --help help for gptscript + -f, --input string Read input from a file ("-" for stdin) ($GPTSCRIPT_INPUT_FILE) + --list-models List the models available and exit ($GPTSCRIPT_LIST_MODELS) + --list-tools List built-in tools and exit ($GPTSCRIPT_LIST_TOOLS) + --no-trunc Do not truncate long log messages ($GPTSCRIPT_NO_TRUNC) + --openai-api-key string OpenAI API KEY ($OPENAI_API_KEY) + --openai-base-url string OpenAI base URL ($OPENAI_BASE_URL) + --openai-org-id string OpenAI organization ID ($OPENAI_ORG_ID) + -o, --output string Save output to a file, or - for stdout ($GPTSCRIPT_OUTPUT) + -q, --quiet No output logging (set --quiet=false to force on even when there is no TTY) ($GPTSCRIPT_QUIET) + --save-chat-state-file string A file to save the chat state to so that a conversation can be resumed with --chat-state ($GPTSCRIPT_SAVE_CHAT_STATE_FILE) + --sub-tool string Use tool of this name, not the first tool in file ($GPTSCRIPT_SUB_TOOL) + --ui Launch the UI ($GPTSCRIPT_UI) + --workspace string Directory to use for the workspace, if specified it will not be deleted on exit ($GPTSCRIPT_WORKSPACE) ``` ### SEE ALSO diff --git a/pkg/cli/gptscript.go b/pkg/cli/gptscript.go index 4458d87b..aafdeacf 100644 --- a/pkg/cli/gptscript.go +++ b/pkg/cli/gptscript.go @@ -23,6 +23,7 @@ import ( "github.com/gptscript-ai/gptscript/pkg/gptscript" "github.com/gptscript-ai/gptscript/pkg/input" "github.com/gptscript-ai/gptscript/pkg/loader" + "github.com/gptscript-ai/gptscript/pkg/loader/github" "github.com/gptscript-ai/gptscript/pkg/monitor" "github.com/gptscript-ai/gptscript/pkg/mvl" "github.com/gptscript-ai/gptscript/pkg/openai" @@ -54,25 +55,26 @@ type GPTScript struct { Output string `usage:"Save output to a file, or - for stdout" short:"o"` EventsStreamTo string `usage:"Stream events to this location, could be a file descriptor/handle (e.g. fd://2), filename, or named pipe (e.g. \\\\.\\pipe\\my-pipe)" name:"events-stream-to"` // Input should not be using GPTSCRIPT_INPUT env var because that is the same value that is set in tool executions - Input string `usage:"Read input from a file (\"-\" for stdin)" short:"f" env:"GPTSCRIPT_INPUT_FILE"` - SubTool string `usage:"Use tool of this name, not the first tool in file" local:"true"` - Assemble bool `usage:"Assemble tool to a single artifact, saved to --output" hidden:"true" local:"true"` - ListModels bool `usage:"List the models available and exit" local:"true"` - ListTools bool `usage:"List built-in tools and exit" local:"true"` - ListenAddress string `usage:"Server listen address" default:"127.0.0.1:0" hidden:"true"` - Chdir string `usage:"Change current working directory" short:"C"` - Daemon bool `usage:"Run tool as a daemon" local:"true" hidden:"true"` - Ports string `usage:"The port range to use for ephemeral daemon ports (ex: 11000-12000)" hidden:"true"` - CredentialContext string `usage:"Context name in which to store credentials" default:"default"` - CredentialOverride []string `usage:"Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234)"` - ChatState string `usage:"The chat state to continue, or null to start a new chat and return the state" local:"true"` - ForceChat bool `usage:"Force an interactive chat session if even the top level tool is not a chat tool" local:"true"` - ForceSequential bool `usage:"Force parallel calls to run sequentially" local:"true"` - Workspace string `usage:"Directory to use for the workspace, if specified it will not be deleted on exit"` - UI bool `usage:"Launch the UI" local:"true" name:"ui"` - DisableTUI bool `usage:"Don't use chat TUI but instead verbose output" local:"true" name:"disable-tui"` - SaveChatStateFile string `usage:"A file to save the chat state to so that a conversation can be resumed with --chat-state" local:"true"` - DefaultModelProvider string `usage:"Default LLM model provider to use, this will override OpenAI settings"` + Input string `usage:"Read input from a file (\"-\" for stdin)" short:"f" env:"GPTSCRIPT_INPUT_FILE"` + SubTool string `usage:"Use tool of this name, not the first tool in file" local:"true"` + Assemble bool `usage:"Assemble tool to a single artifact, saved to --output" hidden:"true" local:"true"` + ListModels bool `usage:"List the models available and exit" local:"true"` + ListTools bool `usage:"List built-in tools and exit" local:"true"` + ListenAddress string `usage:"Server listen address" default:"127.0.0.1:0" hidden:"true"` + Chdir string `usage:"Change current working directory" short:"C"` + Daemon bool `usage:"Run tool as a daemon" local:"true" hidden:"true"` + Ports string `usage:"The port range to use for ephemeral daemon ports (ex: 11000-12000)" hidden:"true"` + CredentialContext string `usage:"Context name in which to store credentials" default:"default"` + CredentialOverride []string `usage:"Credentials to override (ex: --credential-override github.com/example/cred-tool:API_TOKEN=1234)"` + ChatState string `usage:"The chat state to continue, or null to start a new chat and return the state" local:"true"` + ForceChat bool `usage:"Force an interactive chat session if even the top level tool is not a chat tool" local:"true"` + ForceSequential bool `usage:"Force parallel calls to run sequentially" local:"true"` + Workspace string `usage:"Directory to use for the workspace, if specified it will not be deleted on exit"` + UI bool `usage:"Launch the UI" local:"true" name:"ui"` + DisableTUI bool `usage:"Don't use chat TUI but instead verbose output" local:"true" name:"disable-tui"` + SaveChatStateFile string `usage:"A file to save the chat state to so that a conversation can be resumed with --chat-state" local:"true"` + DefaultModelProvider string `usage:"Default LLM model provider to use, this will override OpenAI settings"` + GithubEnterpriseHostname string `usage:"The host name for a Github Enterprise instance to enable for remote loading" local:"true"` readData []byte } @@ -334,6 +336,10 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) { return err } + if r.GithubEnterpriseHostname != "" { + loader.AddVSC(github.LoaderForPrefix(r.GithubEnterpriseHostname)) + } + // If the user is trying to launch the chat-builder UI, then set up the tool and options here. if r.UI { if os.Getenv(system.BinEnvVar) == "" { diff --git a/pkg/loader/github/github.go b/pkg/loader/github/github.go index 2fb01c3d..7b6e79ec 100644 --- a/pkg/loader/github/github.go +++ b/pkg/loader/github/github.go @@ -2,6 +2,7 @@ package github import ( "context" + "crypto/tls" "encoding/json" "fmt" "io" @@ -18,52 +19,63 @@ import ( "github.com/gptscript-ai/gptscript/pkg/types" ) -const ( - GithubPrefix = "github.com/" - githubRepoURL = "https://github.com/%s/%s.git" - githubDownloadURL = "https://raw.githubusercontent.com/%s/%s/%s/%s" - githubCommitURL = "https://api.github.com/repos/%s/%s/commits/%s" -) +type Config struct { + Prefix string + RepoURL string + DownloadURL string + CommitURL string + AuthToken string +} var ( - githubAuthToken = os.Getenv("GITHUB_AUTH_TOKEN") - log = mvl.Package() + log = mvl.Package() + defaultGithubConfig = &Config{ + Prefix: "github.com/", + RepoURL: "https://github.com/%s/%s.git", + DownloadURL: "https://raw.githubusercontent.com/%s/%s/%s/%s", + CommitURL: "https://api.github.com/repos/%s/%s/commits/%s", + AuthToken: os.Getenv("GITHUB_AUTH_TOKEN"), + } ) func init() { loader.AddVSC(Load) } -func getCommitLsRemote(ctx context.Context, account, repo, ref string) (string, error) { - url := fmt.Sprintf(githubRepoURL, account, repo) +func getCommitLsRemote(ctx context.Context, account, repo, ref string, config *Config) (string, error) { + url := fmt.Sprintf(config.RepoURL, account, repo) return git.LsRemote(ctx, url, ref) } // regexp to match a git commit id var commitRegexp = regexp.MustCompile("^[a-f0-9]{40}$") -func getCommit(ctx context.Context, account, repo, ref string) (string, error) { +func getCommit(ctx context.Context, account, repo, ref string, config *Config) (string, error) { if commitRegexp.MatchString(ref) { return ref, nil } - url := fmt.Sprintf(githubCommitURL, account, repo, ref) + url := fmt.Sprintf(config.CommitURL, account, repo, ref) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", fmt.Errorf("failed to create request of %s/%s at %s: %w", account, repo, url, err) } - if githubAuthToken != "" { - req.Header.Add("Authorization", "Bearer "+githubAuthToken) + if config.AuthToken != "" { + req.Header.Add("Authorization", "Bearer "+config.AuthToken) } - resp, err := http.DefaultClient.Do(req) + client := http.DefaultClient + if req.Host == config.Prefix && strings.ToLower(os.Getenv("GH_ENTERPRISE_SKIP_VERIFY")) == "true" { + client = &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + } + resp, err := client.Do(req) if err != nil { return "", err } else if resp.StatusCode != http.StatusOK { c, _ := io.ReadAll(resp.Body) resp.Body.Close() - commit, fallBackErr := getCommitLsRemote(ctx, account, repo, ref) + commit, fallBackErr := getCommitLsRemote(ctx, account, repo, ref, config) if fallBackErr == nil { return commit, nil } @@ -88,8 +100,28 @@ func getCommit(ctx context.Context, account, repo, ref string) (string, error) { return commit.SHA, nil } -func Load(ctx context.Context, _ *cache.Client, urlName string) (string, string, *types.Repo, bool, error) { - if !strings.HasPrefix(urlName, GithubPrefix) { +func LoaderForPrefix(prefix string) func(context.Context, *cache.Client, string) (string, string, *types.Repo, bool, error) { + return func(ctx context.Context, c *cache.Client, urlName string) (string, string, *types.Repo, bool, error) { + return LoadWithConfig(ctx, c, urlName, NewGithubEnterpriseConfig(prefix)) + } +} + +func Load(ctx context.Context, c *cache.Client, urlName string) (string, string, *types.Repo, bool, error) { + return LoadWithConfig(ctx, c, urlName, defaultGithubConfig) +} + +func NewGithubEnterpriseConfig(prefix string) *Config { + return &Config{ + Prefix: prefix, + RepoURL: fmt.Sprintf("https://%s/%%s/%%s.git", prefix), + DownloadURL: fmt.Sprintf("https://raw.%s/%%s/%%s/%%s/%%s", prefix), + CommitURL: fmt.Sprintf("https://%s/api/v3/repos/%%s/%%s/commits/%%s", prefix), + AuthToken: os.Getenv("GH_ENTERPRISE_TOKEN"), + } +} + +func LoadWithConfig(ctx context.Context, _ *cache.Client, urlName string, config *Config) (string, string, *types.Repo, bool, error) { + if !strings.HasPrefix(urlName, config.Prefix) { return "", "", nil, false, nil } @@ -107,12 +139,12 @@ func Load(ctx context.Context, _ *cache.Client, urlName string) (string, string, account, repo := parts[1], parts[2] path := strings.Join(parts[3:], "/") - ref, err := getCommit(ctx, account, repo, ref) + ref, err := getCommit(ctx, account, repo, ref, config) if err != nil { return "", "", nil, false, err } - downloadURL := fmt.Sprintf(githubDownloadURL, account, repo, ref, path) + downloadURL := fmt.Sprintf(config.DownloadURL, account, repo, ref, path) if path == "" || path == "/" || !strings.Contains(parts[len(parts)-1], ".") { var ( testPath string @@ -124,13 +156,20 @@ func Load(ctx context.Context, _ *cache.Client, urlName string) (string, string, } else { testPath = path + "/" + ext } - testURL = fmt.Sprintf(githubDownloadURL, account, repo, ref, testPath) + testURL = fmt.Sprintf(config.DownloadURL, account, repo, ref, testPath) if i == len(types.DefaultFiles)-1 { // no reason to test the last one, we are just going to use it. Being that the default list is only // two elements this loop could have been one check, but hey over-engineered code ftw. break } - if resp, err := http.Head(testURL); err == nil { + headReq, err := http.NewRequest("HEAD", testURL, nil) + if err != nil { + break + } + if config.AuthToken != "" { + headReq.Header.Add("Authorization", "Bearer "+config.AuthToken) + } + if resp, err := http.DefaultClient.Do(headReq); err == nil { _ = resp.Body.Close() if resp.StatusCode == 200 { break @@ -141,9 +180,9 @@ func Load(ctx context.Context, _ *cache.Client, urlName string) (string, string, path = testPath } - return downloadURL, githubAuthToken, &types.Repo{ + return downloadURL, config.AuthToken, &types.Repo{ VCS: "git", - Root: fmt.Sprintf(githubRepoURL, account, repo), + Root: fmt.Sprintf(config.RepoURL, account, repo), Path: gpath.Dir(path), Name: gpath.Base(path), Revision: ref, diff --git a/pkg/loader/github/github_test.go b/pkg/loader/github/github_test.go index d627ee5e..483722bc 100644 --- a/pkg/loader/github/github_test.go +++ b/pkg/loader/github/github_test.go @@ -2,6 +2,10 @@ package github import ( "context" + "fmt" + "net/http" + "net/http/httptest" + "os" "testing" "github.com/gptscript-ai/gptscript/pkg/types" @@ -44,3 +48,56 @@ func TestLoad(t *testing.T) { Revision: "172dfb00b48c6adbbaa7e99270933f95887d1b91", }).Equal(t, repo) } + +func TestLoad_GithubEnterprise(t *testing.T) { + gheToken := "mytoken" + os.Setenv("GH_ENTERPRISE_SKIP_VERIFY", "true") + os.Setenv("GH_ENTERPRISE_TOKEN", gheToken) + s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/gptscript-ai/gptscript/commits/172dfb0": + _, _ = w.Write([]byte(`{"sha": "172dfb00b48c6adbbaa7e99270933f95887d1b91"}`)) + default: + w.WriteHeader(404) + } + })) + defer s.Close() + + serverAddr := s.Listener.Addr().String() + + url, token, repo, ok, err := LoadWithConfig(context.Background(), nil, fmt.Sprintf("%s/gptscript-ai/gptscript/pkg/loader/testdata/tool@172dfb0", serverAddr), NewGithubEnterpriseConfig(serverAddr)) + require.NoError(t, err) + assert.True(t, ok) + autogold.Expect(fmt.Sprintf("https://raw.%s/gptscript-ai/gptscript/172dfb00b48c6adbbaa7e99270933f95887d1b91/pkg/loader/testdata/tool/tool.gpt", serverAddr)).Equal(t, url) + autogold.Expect(&types.Repo{ + VCS: "git", Root: fmt.Sprintf("https://%s/gptscript-ai/gptscript.git", serverAddr), + Path: "pkg/loader/testdata/tool", + Name: "tool.gpt", + Revision: "172dfb00b48c6adbbaa7e99270933f95887d1b91", + }).Equal(t, repo) + autogold.Expect(gheToken).Equal(t, token) + + url, token, repo, ok, err = Load(context.Background(), nil, "github.com/gptscript-ai/gptscript/pkg/loader/testdata/agent@172dfb0") + require.NoError(t, err) + assert.True(t, ok) + autogold.Expect("https://raw.githubusercontent.com/gptscript-ai/gptscript/172dfb00b48c6adbbaa7e99270933f95887d1b91/pkg/loader/testdata/agent/agent.gpt").Equal(t, url) + autogold.Expect(&types.Repo{ + VCS: "git", Root: "https://github.com/gptscript-ai/gptscript.git", + Path: "pkg/loader/testdata/agent", + Name: "agent.gpt", + Revision: "172dfb00b48c6adbbaa7e99270933f95887d1b91", + }).Equal(t, repo) + autogold.Expect("").Equal(t, token) + + url, token, repo, ok, err = Load(context.Background(), nil, "github.com/gptscript-ai/gptscript/pkg/loader/testdata/bothtoolagent@172dfb0") + require.NoError(t, err) + assert.True(t, ok) + autogold.Expect("https://raw.githubusercontent.com/gptscript-ai/gptscript/172dfb00b48c6adbbaa7e99270933f95887d1b91/pkg/loader/testdata/bothtoolagent/agent.gpt").Equal(t, url) + autogold.Expect(&types.Repo{ + VCS: "git", Root: "https://github.com/gptscript-ai/gptscript.git", + Path: "pkg/loader/testdata/bothtoolagent", + Name: "agent.gpt", + Revision: "172dfb00b48c6adbbaa7e99270933f95887d1b91", + }).Equal(t, repo) + autogold.Expect("").Equal(t, token) +} From b8071a888f1bd376da00ffc911db5ec5fce89f2d Mon Sep 17 00:00:00 2001 From: Donnie Adams Date: Fri, 9 Aug 2024 21:45:38 -0400 Subject: [PATCH 19/21] fix: use the default model provider when listing models Signed-off-by: Donnie Adams --- pkg/cli/gptscript.go | 3 +++ pkg/sdkserver/routes.go | 4 ++++ pkg/server/server.go | 3 ++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/cli/gptscript.go b/pkg/cli/gptscript.go index 4926c6fd..2d7e90d9 100644 --- a/pkg/cli/gptscript.go +++ b/pkg/cli/gptscript.go @@ -406,6 +406,9 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) { defer gptScript.Close(true) if r.ListModels { + if r.DefaultModelProvider != "" { + args = append(args, r.DefaultModelProvider) + } return r.listModels(ctx, gptScript, args) } diff --git a/pkg/sdkserver/routes.go b/pkg/sdkserver/routes.go index c0d7a41b..98957624 100644 --- a/pkg/sdkserver/routes.go +++ b/pkg/sdkserver/routes.go @@ -127,6 +127,10 @@ func (s *server) listModels(w http.ResponseWriter, r *http.Request) { providers = reqObject.Providers } + if s.gptscriptOpts.DefaultModelProvider != "" { + providers = append(providers, s.gptscriptOpts.DefaultModelProvider) + } + out, err := s.client.ListModels(r.Context(), providers...) if err != nil { writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to list models: %w", err)) diff --git a/pkg/server/server.go b/pkg/server/server.go index d1c57100..00734c38 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -24,5 +24,6 @@ func ContextWithNewRunID(ctx context.Context) context.Context { } func RunIDFromContext(ctx context.Context) string { - return ctx.Value(execKey{}).(string) + runID, _ := ctx.Value(execKey{}).(string) + return runID } From a383d4f513400b5af0ce69217373dafe72ef058a Mon Sep 17 00:00:00 2001 From: Darren Shepherd Date: Fri, 9 Aug 2024 12:09:21 -0700 Subject: [PATCH 20/21] bug: fix more path issues on windows Stop evaluating env vars locally, but instead use the shell/cmd.exe. --- pkg/engine/cmd.go | 145 ++++++++++------------------------------- pkg/engine/cmd_test.go | 135 -------------------------------------- pkg/engine/daemon.go | 1 + 3 files changed, 37 insertions(+), 244 deletions(-) delete mode 100644 pkg/engine/cmd_test.go diff --git a/pkg/engine/cmd.go b/pkg/engine/cmd.go index 311c743a..14b41183 100644 --- a/pkg/engine/cmd.go +++ b/pkg/engine/cmd.go @@ -11,7 +11,6 @@ import ( "os" "os/exec" "path" - "path/filepath" "runtime" "sort" "strings" @@ -121,7 +120,7 @@ func (e *Engine) runCommand(ctx Context, tool types.Tool, input string, toolCate var extraEnv = []string{ strings.TrimSpace("GPTSCRIPT_CONTEXT=" + strings.Join(instructions, "\n")), } - cmd, stop, err := e.newCommand(ctx.Ctx, extraEnv, tool, input) + cmd, stop, err := e.newCommand(ctx.Ctx, extraEnv, tool, input, true) if err != nil { if toolCategory == NoCategory { return fmt.Sprintf("ERROR: got (%v) while parsing command", err), nil @@ -244,7 +243,11 @@ func appendInputAsEnv(env []string, input string) []string { return env } -func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.Tool, input string) (*exec.Cmd, func(), error) { +func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.Tool, input string, useShell bool) (*exec.Cmd, func(), error) { + if runtime.GOOS == "windows" { + useShell = false + } + envvars := append(e.Env[:], extraEnv...) envvars = appendInputAsEnv(envvars, input) if log.IsDebug() { @@ -254,9 +257,17 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T interpreter, rest, _ := strings.Cut(tool.Instructions, "\n") interpreter = strings.TrimSpace(interpreter)[2:] - args, err := shlex.Split(interpreter) - if err != nil { - return nil, nil, err + var ( + args []string + err error + ) + if useShell { + args = strings.Fields(interpreter) + } else { + args, err = shlex.Split(interpreter) + if err != nil { + return nil, nil, err + } } envvars, err = e.getRuntimeEnv(ctx, tool, args, envvars) @@ -265,17 +276,6 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T } envvars, envMap := envAsMapAndDeDup(envvars) - for i, arg := range args { - args[i] = os.Expand(arg, func(s string) string { - return envMap[s] - }) - } - - // After we determined the interpreter we again interpret the args by env vars - args, err = replaceVariablesForInterpreter(interpreter, envMap) - if err != nil { - return nil, nil, err - } if runtime.GOOS == "windows" && (args[0] == "/bin/bash" || args[0] == "/bin/sh") { args[0] = path.Base(args[0]) @@ -286,8 +286,7 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T } var ( - cmdArgs = args[1:] - stop = func() {} + stop = func() {} ) if strings.TrimSpace(rest) != "" { @@ -305,105 +304,33 @@ func (e *Engine) newCommand(ctx context.Context, extraEnv []string, tool types.T stop() return nil, nil, err } - cmdArgs = append(cmdArgs, f.Name()) - } - - // This is a workaround for Windows, where the command interpreter is constructed with unix style paths - // It converts unix style paths to windows style paths - if runtime.GOOS == "windows" { - parts := strings.Split(args[0], "/") - if parts[len(parts)-1] == "gptscript-go-tool" { - parts[len(parts)-1] = "gptscript-go-tool.exe" - } - - args[0] = filepath.Join(parts...) + args = append(args, f.Name()) } - cmd := exec.CommandContext(ctx, env.Lookup(envvars, args[0]), cmdArgs...) - cmd.Env = compressEnv(envvars) - return cmd, stop, nil -} - -func replaceVariablesForInterpreter(interpreter string, envMap map[string]string) ([]string, error) { - var parts []string - for i, part := range splitByQuotes(interpreter) { - if i%2 == 0 { - part = os.Expand(part, func(s string) string { + // Expand and/or normalize env references + for i, arg := range args { + args[i] = os.Expand(arg, func(s string) string { + if strings.HasPrefix(s, "!") { + return envMap[s[1:]] + } + if !useShell { return envMap[s] - }) - // We protect newly resolved env vars from getting replaced when we do the second Expand - // after shlex. Yeah, crazy. I'm guessing this isn't secure, but just trying to avoid a foot gun. - part = os.Expand(part, func(s string) string { - return "${__" + s + "}" - }) - } - parts = append(parts, part) - } - - parts, err := shlex.Split(strings.Join(parts, "")) - if err != nil { - return nil, err - } - - for i, part := range parts { - parts[i] = os.Expand(part, func(s string) string { - if strings.HasPrefix(s, "__") { - return "${" + s[2:] + "}" } - return envMap[s] + return "${" + s + "}" }) } - return parts, nil -} - -// splitByQuotes will split a string by parsing matching double quotes (with \ as the escape character). -// The return value conforms to the following properties -// 1. s == strings.Join(result, "") -// 2. Even indexes are strings that were not in quotes. -// 3. Odd indexes are strings that were quoted. -// -// Example: s = `In a "quoted string" quotes can be escaped with \"` -// -// result = [`In a `, `"quoted string"`, ` quotes can be escaped with \"`] -func splitByQuotes(s string) (result []string) { - var ( - buf strings.Builder - inEscape, inQuote bool - ) - - for _, c := range s { - if inEscape { - buf.WriteRune(c) - inEscape = false - continue - } - - switch c { - case '"': - if inQuote { - buf.WriteRune(c) - } - result = append(result, buf.String()) - buf.Reset() - if !inQuote { - buf.WriteRune(c) - } - inQuote = !inQuote - case '\\': - inEscape = true - buf.WriteRune(c) - default: - buf.WriteRune(c) - } + if runtime.GOOS == "windows" { + args[0] = strings.ReplaceAll(args[0], "/", "\\") } - if buf.Len() > 0 { - if inQuote { - result = append(result, "") - } - result = append(result, buf.String()) + if useShell { + args = append([]string{"/bin/sh", "-c"}, strings.Join(args, " ")) + } else { + args[0] = env.Lookup(envvars, args[0]) } - return + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Env = compressEnv(envvars) + return cmd, stop, nil } diff --git a/pkg/engine/cmd_test.go b/pkg/engine/cmd_test.go deleted file mode 100644 index 15f72036..00000000 --- a/pkg/engine/cmd_test.go +++ /dev/null @@ -1,135 +0,0 @@ -// File: cmd_test.go -package engine - -import "testing" - -func TestSplitByQuotes(t *testing.T) { - tests := []struct { - name string - input string - expected []string - }{ - { - name: "NoQuotes", - input: "Hello World", - expected: []string{"Hello World"}, - }, - { - name: "ValidQuote", - input: `"Hello" "World"`, - expected: []string{``, `"Hello"`, ` `, `"World"`}, - }, - { - name: "ValidQuoteWithEscape", - input: `"Hello\" World"`, - expected: []string{``, `"Hello\" World"`}, - }, - { - name: "Nothing", - input: "", - expected: []string{}, - }, - { - name: "SpaceInsideQuote", - input: `"Hello World"`, - expected: []string{``, `"Hello World"`}, - }, - { - name: "SingleChar", - input: "H", - expected: []string{"H"}, - }, - { - name: "SingleQuote", - input: `"Hello`, - expected: []string{``, ``, `"Hello`}, - }, - { - name: "ThreeQuotes", - input: `Test "Hello "World" End\"`, - expected: []string{`Test `, `"Hello "`, `World`, ``, `" End\"`}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := splitByQuotes(tt.input) - if !equal(got, tt.expected) { - t.Errorf("splitByQuotes() = %v, want %v", got, tt.expected) - } - }) - } -} - -// Helper function to assert equality of two string slices. -func equal(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i, v := range a { - if v != b[i] { - return false - } - } - return true -} - -// Testing for replaceVariablesForInterpreter -func TestReplaceVariablesForInterpreter(t *testing.T) { - tests := []struct { - name string - interpreter string - envMap map[string]string - expected []string - shouldFail bool - }{ - { - name: "No quotes", - interpreter: "/bin/bash -c ${COMMAND} tail", - envMap: map[string]string{"COMMAND": "echo Hello!"}, - expected: []string{"/bin/bash", "-c", "echo", "Hello!", "tail"}, - }, - { - name: "Quotes Variables", - interpreter: `/bin/bash -c "${COMMAND}" tail`, - envMap: map[string]string{"COMMAND": "Hello, World!"}, - expected: []string{"/bin/bash", "-c", "Hello, World!", "tail"}, - }, - { - name: "Double escape", - interpreter: `/bin/bash -c "${COMMAND}" ${TWO} tail`, - envMap: map[string]string{ - "COMMAND": "Hello, World!", - "TWO": "${COMMAND}", - }, - expected: []string{"/bin/bash", "-c", "Hello, World!", "${COMMAND}", "tail"}, - }, - { - name: "aws cli issue", - interpreter: "aws ${ARGS}", - envMap: map[string]string{ - "ARGS": `ec2 describe-instances --region us-east-1 --query 'Reservations[*].Instances[*].{Instance:InstanceId,State:State.Name}'`, - }, - expected: []string{ - `aws`, - `ec2`, - `describe-instances`, - `--region`, `us-east-1`, - `--query`, `Reservations[*].Instances[*].{Instance:InstanceId,State:State.Name}`, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := replaceVariablesForInterpreter(tt.interpreter, tt.envMap) - if (err != nil) != tt.shouldFail { - t.Errorf("replaceVariablesForInterpreter() error = %v, want %v", err, tt.shouldFail) - return - } - if !equal(got, tt.expected) { - t.Errorf("replaceVariablesForInterpreter() = %v, want %v", got, tt.expected) - } - }) - } -} diff --git a/pkg/engine/daemon.go b/pkg/engine/daemon.go index 4cdab995..113aa1ba 100644 --- a/pkg/engine/daemon.go +++ b/pkg/engine/daemon.go @@ -133,6 +133,7 @@ func (e *Engine) startDaemon(tool types.Tool) (string, error) { }, tool, "{}", + false, ) if err != nil { return url, err From cb46358ec8b92cab677856f4592bbe2010563ab8 Mon Sep 17 00:00:00 2001 From: Darren Shepherd Date: Fri, 9 Aug 2024 11:44:39 -0700 Subject: [PATCH 21/21] feat: manage node/python runtime for local files in dev --- pkg/repos/get.go | 18 ++- pkg/repos/runtimes/busybox/busybox.go | 4 + pkg/repos/runtimes/golang/golang.go | 4 + pkg/repos/runtimes/node/node.go | 26 +++- pkg/repos/runtimes/python/python.go | 38 +++-- pkg/tests/runner_test.go | 23 +++ .../TestRuntimesLocalDev/call1-resp.golden | 16 +++ .../TestRuntimesLocalDev/call1.golden | 37 +++++ .../TestRuntimesLocalDev/call2-resp.golden | 16 +++ .../TestRuntimesLocalDev/call2.golden | 70 +++++++++ .../TestRuntimesLocalDev/call3-resp.golden | 16 +++ .../TestRuntimesLocalDev/call3.golden | 103 +++++++++++++ .../TestRuntimesLocalDev/call4-resp.golden | 9 ++ .../TestRuntimesLocalDev/call4.golden | 136 ++++++++++++++++++ .../TestRuntimesLocalDev/package.json | 15 ++ .../TestRuntimesLocalDev/requirements.txt | 1 + .../testdata/TestRuntimesLocalDev/test.gpt | 34 +++++ 17 files changed, 552 insertions(+), 14 deletions(-) create mode 100644 pkg/tests/testdata/TestRuntimesLocalDev/call1-resp.golden create mode 100644 pkg/tests/testdata/TestRuntimesLocalDev/call1.golden create mode 100644 pkg/tests/testdata/TestRuntimesLocalDev/call2-resp.golden create mode 100644 pkg/tests/testdata/TestRuntimesLocalDev/call2.golden create mode 100644 pkg/tests/testdata/TestRuntimesLocalDev/call3-resp.golden create mode 100644 pkg/tests/testdata/TestRuntimesLocalDev/call3.golden create mode 100644 pkg/tests/testdata/TestRuntimesLocalDev/call4-resp.golden create mode 100644 pkg/tests/testdata/TestRuntimesLocalDev/call4.golden create mode 100644 pkg/tests/testdata/TestRuntimesLocalDev/package.json create mode 100644 pkg/tests/testdata/TestRuntimesLocalDev/requirements.txt create mode 100644 pkg/tests/testdata/TestRuntimesLocalDev/test.gpt diff --git a/pkg/repos/get.go b/pkg/repos/get.go index fc675c58..b43bc63b 100644 --- a/pkg/repos/get.go +++ b/pkg/repos/get.go @@ -28,6 +28,7 @@ type Runtime interface { ID() string Supports(tool types.Tool, cmd []string) bool Setup(ctx context.Context, tool types.Tool, dataRoot, toolSource string, env []string) ([]string, error) + GetHash(tool types.Tool) (string, error) } type noopRuntime struct { @@ -37,6 +38,10 @@ func (n noopRuntime) ID() string { return "none" } +func (n noopRuntime) GetHash(_ types.Tool) (string, error) { + return "", nil +} + func (n noopRuntime) Supports(_ types.Tool, _ []string) bool { return false } @@ -183,8 +188,13 @@ func (m *Manager) setup(ctx context.Context, runtime Runtime, tool types.Tool, e locker.Lock(tool.ID) defer locker.Unlock(tool.ID) + runtimeHash, err := runtime.GetHash(tool) + if err != nil { + return "", nil, err + } + target := filepath.Join(m.storageDir, tool.Source.Repo.Revision, tool.Source.Repo.Path, tool.Source.Repo.Name, runtime.ID()) - targetFinal := filepath.Join(target, tool.Source.Repo.Path) + targetFinal := filepath.Join(target, tool.Source.Repo.Path+runtimeHash) doneFile := targetFinal + ".done" envData, err := os.ReadFile(doneFile) if err == nil { @@ -251,7 +261,11 @@ func (m *Manager) GetContext(ctx context.Context, tool types.Tool, cmd, env []st for _, runtime := range m.runtimes { if runtime.Supports(tool, cmd) { log.Debugf("Runtime %s supports %v", runtime.ID(), cmd) - return m.setup(ctx, runtime, tool, env) + wd, env, err := m.setup(ctx, runtime, tool, env) + if isLocal { + wd = tool.WorkingDir + } + return wd, env, err } } diff --git a/pkg/repos/runtimes/busybox/busybox.go b/pkg/repos/runtimes/busybox/busybox.go index 542ba94a..481ed1fe 100644 --- a/pkg/repos/runtimes/busybox/busybox.go +++ b/pkg/repos/runtimes/busybox/busybox.go @@ -33,6 +33,10 @@ func (r *Runtime) ID() string { return "busybox" } +func (r *Runtime) GetHash(_ types.Tool) (string, error) { + return "", nil +} + func (r *Runtime) Supports(_ types.Tool, cmd []string) bool { if runtime.GOOS != "windows" { return false diff --git a/pkg/repos/runtimes/golang/golang.go b/pkg/repos/runtimes/golang/golang.go index b19cfe90..882e8a0b 100644 --- a/pkg/repos/runtimes/golang/golang.go +++ b/pkg/repos/runtimes/golang/golang.go @@ -35,6 +35,10 @@ func (r *Runtime) ID() string { return "go" + r.Version } +func (r *Runtime) GetHash(_ types.Tool) (string, error) { + return "", nil +} + func (r *Runtime) Supports(tool types.Tool, cmd []string) bool { return tool.Source.IsGit() && len(cmd) > 0 && cmd[0] == "${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool" diff --git a/pkg/repos/runtimes/node/node.go b/pkg/repos/runtimes/node/node.go index fde5103d..d0a9d8cb 100644 --- a/pkg/repos/runtimes/node/node.go +++ b/pkg/repos/runtimes/node/node.go @@ -39,10 +39,7 @@ func (r *Runtime) ID() string { return "node" + r.Version } -func (r *Runtime) Supports(tool types.Tool, cmd []string) bool { - if _, hasPackageJSON := tool.MetaData[packageJSON]; !hasPackageJSON && !tool.Source.IsGit() { - return false - } +func (r *Runtime) Supports(_ types.Tool, cmd []string) bool { for _, testCmd := range []string{"node", "npx", "npm"} { if r.supports(testCmd, cmd) { return true @@ -61,6 +58,15 @@ func (r *Runtime) supports(testCmd string, cmd []string) bool { return runtimeEnv.Matches(cmd, testCmd) } +func (r *Runtime) GetHash(tool types.Tool) (string, error) { + if !tool.Source.IsGit() && tool.WorkingDir != "" { + if s, err := os.Stat(filepath.Join(tool.WorkingDir, packageJSON)); err == nil { + return hash.Digest(tool.WorkingDir + s.ModTime().String())[:7], nil + } + } + return "", nil +} + func (r *Runtime) Setup(ctx context.Context, tool types.Tool, dataRoot, toolSource string, env []string) ([]string, error) { binPath, err := r.getRuntime(ctx, dataRoot) if err != nil { @@ -74,6 +80,8 @@ func (r *Runtime) Setup(ctx context.Context, tool types.Tool, dataRoot, toolSour if _, ok := tool.MetaData[packageJSON]; ok { newEnv = append(newEnv, "GPTSCRIPT_TMPDIR="+toolSource) + } else if !tool.Source.IsGit() && tool.WorkingDir != "" { + newEnv = append(newEnv, "GPTSCRIPT_TMPDIR="+tool.WorkingDir, "GPTSCRIPT_RUNTIME_DEV=true") } return newEnv, nil @@ -120,6 +128,16 @@ func (r *Runtime) runNPM(ctx context.Context, tool types.Tool, toolSource, binDi if err := os.WriteFile(filepath.Join(toolSource, packageJSON), []byte(contents+"\n"), 0644); err != nil { return err } + } else if !tool.Source.IsGit() { + if tool.WorkingDir == "" { + return nil + } + if _, err := os.Stat(filepath.Join(tool.WorkingDir, packageJSON)); errors.Is(fs.ErrNotExist, err) { + return nil + } else if err != nil { + return err + } + cmd.Dir = tool.WorkingDir } return cmd.Run() } diff --git a/pkg/repos/runtimes/python/python.go b/pkg/repos/runtimes/python/python.go index ae24f92a..87b072e5 100644 --- a/pkg/repos/runtimes/python/python.go +++ b/pkg/repos/runtimes/python/python.go @@ -24,8 +24,9 @@ import ( var releasesData []byte const ( - uvVersion = "uv==0.2.33" - requirementsTxt = "requirements.txt" + uvVersion = "uv==0.2.33" + requirementsTxt = "requirements.txt" + gptscriptRequirementsTxt = "requirements-gptscript.txt" ) type Release struct { @@ -47,10 +48,7 @@ func (r *Runtime) ID() string { return "python" + r.Version } -func (r *Runtime) Supports(tool types.Tool, cmd []string) bool { - if _, hasRequirements := tool.MetaData[requirementsTxt]; !hasRequirements && !tool.Source.IsGit() { - return false - } +func (r *Runtime) Supports(_ types.Tool, cmd []string) bool { if runtimeEnv.Matches(cmd, r.ID()) { return true } @@ -177,6 +175,22 @@ func (r *Runtime) getReleaseAndDigest() (string, string, error) { return "", "", fmt.Errorf("failed to find an python runtime for %s", r.Version) } +func (r *Runtime) GetHash(tool types.Tool) (string, error) { + if !tool.Source.IsGit() && tool.WorkingDir != "" { + if _, ok := tool.MetaData[requirementsTxt]; ok { + return "", nil + } + for _, req := range []string{gptscriptRequirementsTxt, requirementsTxt} { + reqFile := filepath.Join(tool.WorkingDir, req) + if s, err := os.Stat(reqFile); err == nil && !s.IsDir() { + return hash.Digest(tool.WorkingDir + s.ModTime().String())[:7], nil + } + } + } + + return "", nil +} + func (r *Runtime) runPip(ctx context.Context, tool types.Tool, toolSource, binDir string, env []string) error { log.InfofCtx(ctx, "Running pip in %s", toolSource) if content, ok := tool.MetaData[requirementsTxt]; ok { @@ -189,8 +203,16 @@ func (r *Runtime) runPip(ctx context.Context, tool types.Tool, toolSource, binDi return cmd.Run() } - for _, req := range []string{"requirements-gptscript.txt", requirementsTxt} { - reqFile := filepath.Join(toolSource, req) + reqPath := toolSource + if !tool.Source.IsGit() { + if tool.WorkingDir == "" { + return nil + } + reqPath = tool.WorkingDir + } + + for _, req := range []string{gptscriptRequirementsTxt, requirementsTxt} { + reqFile := filepath.Join(reqPath, req) if s, err := os.Stat(reqFile); err == nil && !s.IsDir() { cmd := debugcmd.New(ctx, uvBin(binDir), "pip", "install", "-r", reqFile) cmd.Env = env diff --git a/pkg/tests/runner_test.go b/pkg/tests/runner_test.go index 424c84c1..141e6aff 100644 --- a/pkg/tests/runner_test.go +++ b/pkg/tests/runner_test.go @@ -1018,3 +1018,26 @@ func TestRuntimes(t *testing.T) { }) r.RunDefault() } + +func TestRuntimesLocalDev(t *testing.T) { + r := tester.NewRunner(t) + r.RespondWith(tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "py", + Arguments: "{}", + }, + }, tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "node", + Arguments: "{}", + }, + }, tester.Result{ + Func: types.CompletionFunctionCall{ + Name: "bash", + Arguments: "{}", + }, + }) + r.RunDefault() + _ = os.RemoveAll("testdata/TestRuntimesLocalDev/node_modules") + _ = os.RemoveAll("testdata/TestRuntimesLocalDev/package-lock.json") +} diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call1-resp.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call1-resp.golden new file mode 100644 index 00000000..1d53670a --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call1-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call1.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call1.golden new file mode 100644 index 00000000..7e775029 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call1.golden @@ -0,0 +1,37 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call2-resp.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call2-resp.golden new file mode 100644 index 00000000..4806793c --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call2-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call2.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call2.golden new file mode 100644 index 00000000..cc1fd1b7 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call2.golden @@ -0,0 +1,70 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call3-resp.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call3-resp.golden new file mode 100644 index 00000000..1103f824 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call3-resp.golden @@ -0,0 +1,16 @@ +`{ + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + } + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call3.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call3.golden new file mode 100644 index 00000000..7c928c07 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call3.golden @@ -0,0 +1,103 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "node worked\n" + } + ], + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call4-resp.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call4-resp.golden new file mode 100644 index 00000000..8135a8c9 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call4-resp.golden @@ -0,0 +1,9 @@ +`{ + "role": "assistant", + "content": [ + { + "text": "TEST RESULT CALL: 4" + } + ], + "usage": {} +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/call4.golden b/pkg/tests/testdata/TestRuntimesLocalDev/call4.golden new file mode 100644 index 00000000..b95b880d --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/call4.golden @@ -0,0 +1,136 @@ +`{ + "model": "gpt-4o", + "tools": [ + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:py", + "name": "py", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:node", + "name": "node", + "parameters": null + } + }, + { + "function": { + "toolID": "testdata/TestRuntimesLocalDev/test.gpt:bash", + "name": "bash", + "parameters": null + } + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "text": "Dummy" + } + ], + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "py worked\r\n" + } + ], + "toolCall": { + "index": 0, + "id": "call_1", + "function": { + "name": "py", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "node worked\n" + } + ], + "toolCall": { + "index": 1, + "id": "call_2", + "function": { + "name": "node", + "arguments": "{}" + } + }, + "usage": {} + }, + { + "role": "assistant", + "content": [ + { + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + } + } + ], + "usage": {} + }, + { + "role": "tool", + "content": [ + { + "text": "bash works\n" + } + ], + "toolCall": { + "index": 2, + "id": "call_3", + "function": { + "name": "bash", + "arguments": "{}" + } + }, + "usage": {} + } + ] +}` diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/package.json b/pkg/tests/testdata/TestRuntimesLocalDev/package.json new file mode 100644 index 00000000..d5f400a1 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/package.json @@ -0,0 +1,15 @@ +{ + "name": "chalk-example", + "version": "1.0.0", + "type": "module", + "description": "A simple example project to demonstrate the use of chalk", + "main": "example.js", + "scripts": { + "start": "node example.js" + }, + "author": "Your Name", + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0" + } +} diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/requirements.txt b/pkg/tests/testdata/TestRuntimesLocalDev/requirements.txt new file mode 100644 index 00000000..f2293605 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/pkg/tests/testdata/TestRuntimesLocalDev/test.gpt b/pkg/tests/testdata/TestRuntimesLocalDev/test.gpt new file mode 100644 index 00000000..454ffce0 --- /dev/null +++ b/pkg/tests/testdata/TestRuntimesLocalDev/test.gpt @@ -0,0 +1,34 @@ +name: first +tools: py, node, bash + +Dummy + +--- +name: py + +#!/usr/bin/env python3 + +import requests +import platform + +# this is dumb hack to get the line endings to always be \r\n so the golden files match +# on both linux and windows +if platform.system() == 'Windows': + print('py worked') +else: + print('py worked\r') + +--- +name: node + +#!/usr/bin/env node + +import chalk from 'chalk'; +console.log("node worked") + +--- +name: bash + +#!/bin/bash + +echo bash works \ No newline at end of file