diff --git a/app/go.mod b/app/go.mod index 3ccfbffd..18805a5b 100644 --- a/app/go.mod +++ b/app/go.mod @@ -12,16 +12,20 @@ require ( github.com/go-logr/logr v1.3.0 github.com/go-logr/zapr v1.3.0 github.com/google/go-cmp v0.6.0 + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 + github.com/hashicorp/go-retryablehttp v0.7.5 github.com/jlewi/foyle/protos/go v0.0.0-00010101000000-000000000000 github.com/jlewi/hydros v0.0.6 github.com/jlewi/monogo v0.0.0-20240123191147-401afe194d74 github.com/pkg/errors v0.9.1 + github.com/sashabaranov/go-openai v1.20.4 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 github.com/timtadh/lexmachine v0.2.3 github.com/yuin/goldmark v1.4.13 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 go.uber.org/zap v1.27.0 google.golang.org/grpc v1.62.1 gopkg.in/yaml.v3 v3.0.1 @@ -74,6 +78,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/google/btree v1.0.1 // indirect github.com/google/gnostic v0.6.9 // indirect github.com/google/go-containerregistry v0.18.0 // indirect github.com/google/gofuzz v1.2.0 // indirect @@ -81,6 +86,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/imdario/mergo v0.3.13 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -104,6 +110,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc3 // indirect github.com/pelletier/go-toml/v2 v2.2.0 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/rivo/uniseg v0.4.2 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect @@ -123,7 +130,6 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xlab/treeprint v1.1.0 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect go.opentelemetry.io/otel v1.21.0 // indirect go.opentelemetry.io/otel/metric v1.21.0 // indirect go.opentelemetry.io/otel/trace v1.21.0 // indirect diff --git a/app/go.sum b/app/go.sum index 007e3bfa..6e3814dc 100644 --- a/app/go.sum +++ b/app/go.sum @@ -94,6 +94,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A= github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= +github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= +github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flowstack/go-jsonschema v0.1.1/go.mod h1:yL7fNggx1o8rm9RlgXv7hTBWxdBM0rVwpMwimd3F3N0= @@ -174,6 +176,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/gnostic v0.6.9 h1:ZK/5VhkoX835RikCHpSUJV9a+S3e1zLh59YnyWeBW+0= github.com/google/gnostic v0.6.9/go.mod h1:Nm8234We1lq6iB9OmlgNv3nH91XLLVZHCDayfA3xq+E= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -202,9 +206,18 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfF github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= +github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= @@ -253,6 +266,8 @@ github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2 h1:JAEbJn3j/FrhdWA9jW8B5ajsLIjeuEHLi8xE4fk997o= github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= @@ -276,6 +291,8 @@ github.com/opencontainers/image-spec v1.1.0-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0 github.com/opencontainers/image-spec v1.1.0-rc3/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -295,6 +312,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sashabaranov/go-openai v1.20.4 h1:095xQ/fAtRa0+Rj21sezVJABgKfGPNbyx/sAN/hJUmg= +github.com/sashabaranov/go-openai v1.20.4/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= diff --git a/app/pkg/agent/agent.go b/app/pkg/agent/agent.go index 2d7df6ca..f70c0323 100644 --- a/app/pkg/agent/agent.go +++ b/app/pkg/agent/agent.go @@ -2,23 +2,135 @@ package agent import ( "context" + "strings" + "github.com/jlewi/foyle/app/pkg/config" + "github.com/jlewi/foyle/app/pkg/docs" + "github.com/jlewi/foyle/app/pkg/logs" + "github.com/jlewi/foyle/app/pkg/oai" "github.com/jlewi/foyle/protos/go/foyle/v1alpha1" + "github.com/pkg/errors" + "github.com/sashabaranov/go-openai" +) + +const ( + maxTries = 3 + // MaxDocChars is an upper limit for the number of characters to include in prompts to avoid hitting + // OpenAI's context length limits. This can be an upper bound because if we get a context length exceeded + // error the code will automatically try to shrink the document even further. + // We use the heuristic 1 token ~ 2 characters + // We are currently using GPT3.5 which has a context window of 16385 tokens. + // (https://platform.openai.com/docs/models/gpt-3-5-turbo) + // If we use 50% of that's 16000 characters. + MaxDocChars = 16000 + temperature = 0.9 ) // Agent is the agent. type Agent struct { v1alpha1.UnimplementedGenerateServiceServer + client *openai.Client + config config.Config +} + +func NewAgent(cfg config.Config, client *openai.Client) (*Agent, error) { + return &Agent{ + client: client, + config: cfg, + }, nil } +func (a *Agent) Generate(ctx context.Context, req *v1alpha1.GenerateRequest) (*v1alpha1.GenerateResponse, error) { + blocks, err := a.completeWithRetries(ctx, req) -func (e *Agent) Generate(context.Context, *v1alpha1.GenerateRequest) (*v1alpha1.GenerateResponse, error) { + if err != nil { + // TODO(jeremy): Should we set a status code? + return nil, err + } resp := &v1alpha1.GenerateResponse{ - Blocks: []*v1alpha1.Block{ - { - Kind: v1alpha1.BlockKind_MARKUP, - Contents: "Hello From The Foyle Server! Your generate request was recieved.", - }, - }, + Blocks: blocks, } return resp, nil } + +func (a *Agent) completeWithRetries(ctx context.Context, req *v1alpha1.GenerateRequest) ([]*v1alpha1.Block, error) { + log := logs.FromContext(ctx) + + t := docs.NewTailer(req.Doc.GetBlocks(), MaxDocChars) + for try := 0; try < maxTries; try++ { + + args := promptArgs{ + Document: t.Text(), + } + + var sb strings.Builder + if err := promptTemplate.Execute(&sb, args); err != nil { + return nil, errors.Wrapf(err, "Failed to execute prompt template") + } + + messages := []openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleSystem, + Content: systemPrompt, + }, + {Role: openai.ChatMessageRoleUser, + Content: sb.String(), + }, + } + request := openai.ChatCompletionRequest{ + Model: openai.GPT3Dot5Turbo0125, + Messages: messages, + MaxTokens: 2000, + Temperature: temperature, + } + + log.Info("OpenAI:CreateChatCompletion", "request", request) + resp, err := a.client.CreateChatCompletion(ctx, request) + + if err != nil { + if oai.ErrorIs(err, oai.ContextLengthExceededCode) { + log.Info("OpenAI:ContextLengthExceeded", "err", err) + if !t.Shorten() { + return nil, errors.Wrapf(err, "the document can't be shortened any further to fit within the context window") + } + continue + } + // TODO(jeremy): Should we surface the error to the user as blocks in the notebook + return nil, errors.Wrapf(err, "CreateChatCompletion failed") + } + + log.Info("OpenAI:CreateChatCompletion response", "resp", resp) + + blocks, err := a.parseResponse(ctx, &resp) + if err != nil { + return nil, errors.Wrapf(err, "Failed to parse response") + } + + return blocks, nil + } + err := errors.Errorf("Failed to generate a chat completion after %d tries", maxTries) + log.Error(err, "Failed to generate a chat completion", "maxTries", maxTries) + return nil, err +} + +func (a *Agent) parseResponse(ctx context.Context, resp *openai.ChatCompletionResponse) ([]*v1alpha1.Block, error) { + log := logs.FromContext(ctx) + allBlocks := make([]*v1alpha1.Block, 0, 10) + for _, choice := range resp.Choices { + if choice.Message.Content == "" { + continue + } + + blocks, err := docs.MarkdownToBlocks(choice.Message.Content) + if err != nil { + log.Error(err, "Failed to parse markdown to blocks", "markdown", choice.Message.Content) + b := &v1alpha1.Block{ + Kind: v1alpha1.BlockKind_MARKUP, + Contents: choice.Message.Content, + } + allBlocks = append(allBlocks, b) + continue + } + + allBlocks = append(allBlocks, blocks...) + } + return allBlocks, nil +} diff --git a/app/pkg/agent/agent_test.go b/app/pkg/agent/agent_test.go new file mode 100644 index 00000000..cc8f1df7 --- /dev/null +++ b/app/pkg/agent/agent_test.go @@ -0,0 +1,57 @@ +package agent + +import ( + "context" + "os" + "testing" + + "github.com/jlewi/foyle/app/pkg/config" + "github.com/jlewi/foyle/app/pkg/oai" + "github.com/jlewi/foyle/protos/go/foyle/v1alpha1" + "go.uber.org/zap" +) + +func Test_Generate(t *testing.T) { + if os.Getenv("GITHUB_ACTIONS") != "" { + t.Skipf("Test is skipped in GitHub actions") + } + + if err := config.InitViper(nil); err != nil { + t.Fatalf("Failed to initialize viper: %v", err) + } + cfg := config.GetConfig() + + // Setup logs + c := zap.NewDevelopmentConfig() + log, err := c.Build() + if err != nil { + t.Fatalf("Error creating logger; %v", err) + } + zap.ReplaceGlobals(log) + + client, err := oai.NewClient(*cfg) + if err != nil { + t.Fatalf("Error creating OpenAI client; %v", err) + } + agent, err := NewAgent(*cfg, client) + + if err != nil { + t.Fatalf("Error creating agent; %v", err) + } + + req := &v1alpha1.GenerateRequest{ + Doc: &v1alpha1.Doc{ + Blocks: []*v1alpha1.Block{ + { + Contents: "Use gcloud to list all the cloud build jobs in project foyle", + }, + }, + }, + } + resp, err := agent.Generate(context.Background(), req) + if err != nil { + t.Fatalf("Error generating; %v", err) + } + + t.Logf("Response: %+v", resp) +} diff --git a/app/pkg/agent/prompt.go b/app/pkg/agent/prompt.go new file mode 100644 index 00000000..ed3d51e7 --- /dev/null +++ b/app/pkg/agent/prompt.go @@ -0,0 +1,30 @@ +package agent + +import ( + _ "embed" + "text/template" +) + +const ( + systemPrompt = `You are a helpful AI assistant for software developers. You are helping software engineers write markdown documents to deploy +and operate software. Your job is to help users reason about problems and tasks and come up with the appropriate +commands to accomplish them. You should never try to execute commands. You should always tell the user +to execute the commands themselves. To help the user place the commands inside a code block with the language set to +bash. Users can then execute the commands inside VSCode notebooks. The output will then be appended to the document. +You can then use that output to reason about the next steps. + +You are only helping users with tasks related to building, deploying, and operating software. You should interpret +any questions or commands in that context. +` +) + +//go:embed prompt.tmpl +var promptTemplateString string + +var ( + promptTemplate = template.Must(template.New("prompt").Parse(promptTemplateString)) +) + +type promptArgs struct { + Document string +} diff --git a/app/pkg/agent/prompt.tmpl b/app/pkg/agent/prompt.tmpl new file mode 100644 index 00000000..a6bb0226 --- /dev/null +++ b/app/pkg/agent/prompt.tmpl @@ -0,0 +1,12 @@ +Please continue writing this markdown document to deal with any tasks or issues listed +in the document. The document is a markdown document. It will contain a description of the task +or problem, I need your help with. It will then contain one or more code blocks containing commands +to be executed to accomplish the task or obtain information needed to figure out the problem. +If a command has already been executed the output of the command will be provided in a code block +with the language `output`. Use the output to help you figure out the problem or complete the task. +If you need me to execute a command please provide the command in a code block and I will execute it +and then add the output to the document. + +Here's the document: + +{{.Document}} \ No newline at end of file diff --git a/app/pkg/config/config.go b/app/pkg/config/config.go index 5149445f..e710dae4 100644 --- a/app/pkg/config/config.go +++ b/app/pkg/config/config.go @@ -41,6 +41,7 @@ type Config struct { Logging Logging `json:"logging" yaml:"logging"` Server ServerConfig `json:"server" yaml:"server"` Assets AssetConfig `json:"assets" yaml:"assets"` + OpenAI OpenAIConfig `json:"openai" yaml:"openai"` } // ServerConfig configures the server @@ -65,6 +66,11 @@ type ServerConfig struct { HttpMaxWriteTimeout time.Duration `json:"httpMaxWriteTimeout" yaml:"httpMaxWriteTimeout"` } +type OpenAIConfig struct { + // APIKeyFile is the path to the file containing the API key + APIKeyFile string `json:"apiKeyFile" yaml:"apiKeyFile"` +} + type CorsConfig struct { // AllowedOrigins is a list of origins allowed to make cross-origin requests. AllowedOrigins []string `json:"allowedOrigins" yaml:"allowedOrigins"` diff --git a/app/pkg/docs/converters.go b/app/pkg/docs/converters.go index 30438c98..f2aa289e 100644 --- a/app/pkg/docs/converters.go +++ b/app/pkg/docs/converters.go @@ -67,6 +67,7 @@ func MarkdownToBlocks(mdText string) ([]*v1alpha1.Block, error) { if err := renderer.Render(&sb, source, node); err != nil { return ast.WalkStop, err } + newBlock := &v1alpha1.Block{ Kind: v1alpha1.BlockKind_MARKUP, Contents: sb.String(), diff --git a/app/pkg/docs/tailer.go b/app/pkg/docs/tailer.go new file mode 100644 index 00000000..ebf37480 --- /dev/null +++ b/app/pkg/docs/tailer.go @@ -0,0 +1,57 @@ +package docs + +import ( + "strings" + + "github.com/jlewi/foyle/protos/go/foyle/v1alpha1" +) + +// Tailer is a helper for building a markdown representation of the tail end of a document. +// It is intended to be stateful and used to iteratively find a suffix of a document that fits within a certain length +// (i.e. the context length of the model). +type Tailer struct { + // mdBlocks keeps track of the markdown blocks + mdBlocks []string + + // firstBlock is the index of the first block to include in the prompt + firstBlock int +} + +func NewTailer(blocks []*v1alpha1.Block, maxCharLen int) *Tailer { + mdBlocks := make([]string, len(blocks)) + + length := 0 + firstBlock := len(blocks) - 1 + for ; firstBlock >= 0; firstBlock-- { + block := blocks[firstBlock] + md := BlockToMarkdown(block) + if length+len(md) > maxCharLen { + break + } + mdBlocks[firstBlock] = md + } + + return &Tailer{ + mdBlocks: mdBlocks, + } +} + +// Text returns the text of the doc. +func (p *Tailer) Text() string { + var sb strings.Builder + for i := p.firstBlock; i < len(p.mdBlocks); i++ { + sb.WriteString(p.mdBlocks[i]) + } + return sb.String() +} + +// Shorten shortens the doc that will be generated on the next call to Text. +// Return false if the doc can't be shortened any further. +func (p *Tailer) Shorten() bool { + if p.firstBlock+1 >= len(p.mdBlocks) { + return false + } + + p.firstBlock += 1 + return true +} diff --git a/app/pkg/oai/client.go b/app/pkg/oai/client.go new file mode 100644 index 00000000..c4b5592e --- /dev/null +++ b/app/pkg/oai/client.go @@ -0,0 +1,40 @@ +package oai + +import ( + "strings" + + "github.com/hashicorp/go-retryablehttp" + "github.com/jlewi/foyle/app/pkg/config" + "github.com/jlewi/hydros/pkg/files" + "github.com/pkg/errors" + "github.com/sashabaranov/go-openai" +) + +// NewClient helper function to create a new OpenAI client from a config +func NewClient(cfg config.Config) (*openai.Client, error) { + if cfg.OpenAI.APIKeyFile == "" { + return nil, errors.New("OpenAI APIKeyFile is required") + } + apiKeyBytes, err := files.Read(cfg.OpenAI.APIKeyFile) + if err != nil { + return nil, errors.Wrapf(err, "could not read OpenAI APIKeyFile: %v", cfg.OpenAI.APIKeyFile) + } + // make sure there is no leading or trailing whitespace + apiKey := strings.TrimSpace(string(apiKeyBytes)) + + // ************************************************************************ + // Setup middleware + // ************************************************************************ + + // Handle retryable errors + // To handle retryable errors we use hashi corp's retryable client. This client will automatically retry on + // retryable errors like 429; rate limiting + retryClient := retryablehttp.NewClient() + httpClient := retryClient.StandardClient() + + clientCfg := openai.DefaultConfig(apiKey) + clientCfg.HTTPClient = httpClient + client := openai.NewClientWithConfig(clientCfg) + + return client, nil +} diff --git a/app/pkg/oai/errors.go b/app/pkg/oai/errors.go new file mode 100644 index 00000000..cd5c3536 --- /dev/null +++ b/app/pkg/oai/errors.go @@ -0,0 +1,23 @@ +package oai + +import "github.com/sashabaranov/go-openai" + +const ( + // ContextLengthExceededCode the error code returned by OpenAI to indicate the context length was exceeded + ContextLengthExceededCode = "context_length_exceeded" +) + +// ErrorIs checks if the error is an OpenAI error with the given code. +func ErrorIs(err error, oaiCode string) bool { + apiErr, ok := err.(*openai.APIError) + if !ok { + return false + } + + val, ok := apiErr.Code.(string) + if !ok { + return false + } + + return val == oaiCode +} diff --git a/app/pkg/server/server.go b/app/pkg/server/server.go index fcace24c..c739cfec 100644 --- a/app/pkg/server/server.go +++ b/app/pkg/server/server.go @@ -24,6 +24,7 @@ import ( "github.com/jlewi/foyle/app/pkg/config" "github.com/jlewi/foyle/app/pkg/executor" "github.com/jlewi/foyle/app/pkg/logs" + "github.com/jlewi/foyle/app/pkg/oai" "github.com/jlewi/foyle/protos/go/foyle/v1alpha1" "github.com/pkg/errors" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" @@ -56,10 +57,22 @@ func NewServer(config config.Config) (*Server, error) { if err != nil { return nil, err } + + oaiClient, err := oai.NewClient(config) + + if err != nil { + return nil, err + } + + a, err := agent.NewAgent(config, oaiClient) + if err != nil { + return nil, err + } + s := &Server{ config: config, executor: e, - agent: &agent.Agent{}, + agent: a, } if err := s.createGinEngine(); err != nil {