From 9dad2b0ba446a7b0beeb44d766ee2537dec42e5a Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Thu, 10 Apr 2025 23:36:44 +0200 Subject: [PATCH] Add Github reviewer and improve reasoning --- core/agent/actions.go | 102 ++++++--- core/agent/agent.go | 1 + services/actions.go | 9 + services/actions/githubprcommenter.go | 212 +++---------------- services/actions/githubprreviewer.go | 286 ++++++++++++++++++++++++++ 5 files changed, 388 insertions(+), 222 deletions(-) create mode 100644 services/actions/githubprreviewer.go diff --git a/core/agent/actions.go b/core/agent/actions.go index d40704d..fed69f8 100644 --- a/core/agent/actions.go +++ b/core/agent/actions.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/mudler/LocalAGI/core/action" "github.com/mudler/LocalAGI/core/types" @@ -358,7 +359,7 @@ func (a *Agent) prepareHUD() (promptHUD *PromptHUD) { func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.ChatCompletionMessage, maxRetries int) (types.Action, types.ActionParams, string, error) { c := messages - xlog.Debug("picking action", "messages", messages) + xlog.Debug("[pickAction] picking action", "messages", messages) if !a.options.forceReasoning { xlog.Debug("not forcing reasoning") @@ -389,7 +390,7 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai. return chosenAction, thought.actionParams, thought.message, nil } - xlog.Debug("forcing reasoning") + xlog.Debug("[pickAction] forcing reasoning") prompt, err := renderTemplate(templ, a.prepareHUD(), a.availableActions(), "") if err != nil { @@ -406,44 +407,77 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai. }, c...) } - // We also could avoid to use functions here and get just a reply from the LLM - // and then use the reply to get the action - thought, err := a.decision(ctx, - c, - types.Actions{action.NewReasoning()}.ToTools(), - action.NewReasoning().Definition().Name, maxRetries) - if err != nil { - return nil, nil, "", err - } - reason := "" - response := &action.ReasoningResponse{} - if thought.actionParams != nil { - if err := thought.actionParams.Unmarshal(response); err != nil { - return nil, nil, "", err - } - reason = response.Reasoning - } - if thought.message != "" { - reason = thought.message - } - - xlog.Debug("thought", "reason", reason) - - // From the thought, get the action call - // Get all the available actions IDs actionsID := []string{} for _, m := range a.availableActions() { actionsID = append(actionsID, m.Definition().Name.String()) } - intentionsTools := action.NewIntention(actionsID...) + + thoughtPromptStringBuilder := strings.Builder{} + thoughtPromptStringBuilder.WriteString("You have to pick an action based on the conversation and the prompt. Describe the full reasoning process for your choice. Here is a list of actions: ") + for _, m := range a.availableActions() { + thoughtPromptStringBuilder.WriteString( + m.Definition().Name.String() + ": " + m.Definition().Description + "\n", + ) + } + + thoughtPromptStringBuilder.WriteString("To not use any action, respond with 'none'") + + //thoughtPromptStringBuilder.WriteString("\n\nConversation: " + Messages(c).String()) + + thoughtPrompt := thoughtPromptStringBuilder.String() + + xlog.Debug("[pickAction] thought", "prompt", thoughtPrompt) + + thought, err := a.askLLM(ctx, + append(c, openai.ChatCompletionMessage{ + Role: "user", + Content: thoughtPrompt, + }), + maxRetries, + ) + if err != nil { + return nil, nil, "", err + } + originalReasoning := thought.Content + + xlog.Debug("[pickAction] thought", "reason", originalReasoning) + + thought, err = a.askLLM(ctx, + []openai.ChatCompletionMessage{ + { + Role: "system", + Content: "Your only objective is to return the string of the action to take based on the user input", + }, + { + Role: "user", + Content: "Given the following sentence, answer with the only action to take: " + originalReasoning, + }}, + maxRetries, + ) + if err != nil { + return nil, nil, "", err + } + reason := thought.Content + + xlog.Debug("[pickAction] filtered thought", "reason", reason) + + // From the thought, get the action call + // Get all the available actions IDs + + intentionsTools := action.NewIntention(append(actionsID, "none")...) // NOTE: we do not give the full conversation here to pick the action // to avoid hallucinations params, err := a.decision(ctx, - []openai.ChatCompletionMessage{{ - Role: "assistant", - Content: reason, - }, + []openai.ChatCompletionMessage{ + { + Role: "system", + Content: "You have to pick the correct action based on the user reasoning", + }, + { + Role: "user", + Content: reason, + }, }, types.Actions{intentionsTools}.ToTools(), intentionsTools.Definition().Name, maxRetries) @@ -463,7 +497,7 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai. } if actionChoice.Tool == "" || actionChoice.Tool == "none" { - return nil, nil, "", fmt.Errorf("no intent detected") + return nil, nil, "", nil } // Find the action @@ -472,5 +506,5 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai. return nil, nil, "", fmt.Errorf("no action found for intent:" + actionChoice.Tool) } - return chosenAction, nil, actionChoice.Reasoning, nil + return chosenAction, nil, originalReasoning, nil } diff --git a/core/agent/agent.go b/core/agent/agent.go index deaf998..4a3c882 100644 --- a/core/agent/agent.go +++ b/core/agent/agent.go @@ -670,6 +670,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) { !chosenAction.Definition().Name.Is(action.ReplyActionName) { xlog.Info("Following action", "action", followingAction.Definition().Name, "agent", a.Character.Name) + job.ConversationHistory = conv // We need to do another action (?) // The agent decided to do another action diff --git a/services/actions.go b/services/actions.go index b352bb1..1e004fd 100644 --- a/services/actions.go +++ b/services/actions.go @@ -28,6 +28,7 @@ const ( ActionGithubIssueCommenter = "github-issue-commenter" ActionGithubPRReader = "github-pr-reader" ActionGithubPRCommenter = "github-pr-commenter" + ActionGithubPRReviewer = "github-pr-reviewer" ActionGithubREADME = "github-readme" ActionScraper = "scraper" ActionWikipedia = "wikipedia" @@ -53,6 +54,7 @@ var AvailableActions = []string{ ActionGithubIssueCommenter, ActionGithubPRReader, ActionGithubPRCommenter, + ActionGithubPRReviewer, ActionGithubREADME, ActionScraper, ActionBrowse, @@ -114,6 +116,8 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP a = actions.NewGithubPRReader(config) case ActionGithubPRCommenter: a = actions.NewGithubPRCommenter(config) + case ActionGithubPRReviewer: + a = actions.NewGithubPRReviewer(config) case ActionGithubIssueCommenter: a = actions.NewGithubIssueCommenter(config) case ActionGithubRepositoryGet: @@ -217,6 +221,11 @@ func ActionsConfigMeta() []config.FieldGroup { Label: "GitHub PR Commenter", Fields: actions.GithubPRCommenterConfigMeta(), }, + { + Name: "github-pr-reviewer", + Label: "GitHub PR Reviewer", + Fields: actions.GithubPRReviewerConfigMeta(), + }, { Name: "twitter-post", Label: "Twitter Post", diff --git a/services/actions/githubprcommenter.go b/services/actions/githubprcommenter.go index e15a62b..810b6e1 100644 --- a/services/actions/githubprcommenter.go +++ b/services/actions/githubprcommenter.go @@ -3,11 +3,8 @@ package actions import ( "context" "fmt" - "io" "regexp" "strconv" - "strings" - "time" "github.com/google/go-github/v69/github" "github.com/mudler/LocalAGI/core/types" @@ -124,16 +121,10 @@ func NewGithubPRCommenter(config map[string]string) *GithubPRCommenter { func (g *GithubPRCommenter) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) { result := struct { - Repository string `json:"repository"` - Owner string `json:"owner"` - PRNumber int `json:"pr_number"` - GeneralComment string `json:"general_comment"` - Comments []struct { - File string `json:"file"` - Line int `json:"line"` - Comment string `json:"comment"` - StartLine int `json:"start_line,omitempty"` - } `json:"comments"` + Repository string `json:"repository"` + Owner string `json:"owner"` + PRNumber int `json:"pr_number"` + Comment string `json:"comment"` }{} err := params.Unmarshal(&result) if err != nil { @@ -159,134 +150,31 @@ func (g *GithubPRCommenter) Run(ctx context.Context, params types.ActionParams) return types.ActionResult{Result: fmt.Sprintf("Pull request #%d is not open (current state: %s)", result.PRNumber, *pr.State)}, nil } - // Get the list of changed files to verify the files exist in the PR - files, _, err := g.client.PullRequests.ListFiles(ctx, result.Owner, result.Repository, result.PRNumber, &github.ListOptions{}) - if err != nil { - return types.ActionResult{}, fmt.Errorf("failed to list PR files: %w", err) + if result.Comment == "" { + return types.ActionResult{Result: "No comment provided"}, nil } - // Create a map of valid files with their commit info - validFiles := make(map[string]*commitFileInfo) - for _, file := range files { - if *file.Status != "deleted" { - info, err := getCommitInfo(file) - if err != nil { - continue - } - validFiles[*file.Filename] = info - } - } + // Try both PullRequests and Issues API for general comments + var resp *github.Response - // Process each comment - var results []string - for _, comment := range result.Comments { - // Check if file exists in PR - fileInfo, exists := validFiles[comment.File] - if !exists { - availableFiles := make([]string, 0, len(validFiles)) - for f := range validFiles { - availableFiles = append(availableFiles, f) - } - results = append(results, fmt.Sprintf("Error: File %s not found in PR #%d. Available files: %v", - comment.File, result.PRNumber, availableFiles)) - continue - } + // First try PullRequests API + _, resp, err = g.client.PullRequests.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.PullRequestComment{ + Body: &result.Comment, + }) - // Check if line is in a changed hunk - if !fileInfo.isLineInChange(comment.Line) { - results = append(results, fmt.Sprintf("Error: Line %d is not in a changed hunk in file %s", - comment.Line, comment.File)) - continue - } - - // Calculate position - position := fileInfo.calculatePosition(comment.Line) - if position == nil { - results = append(results, fmt.Sprintf("Error: Could not calculate position for line %d in file %s", - comment.Line, comment.File)) - continue - } - - // Create the review comment - reviewComment := &github.PullRequestComment{ - Path: &comment.File, - Line: &comment.Line, - Body: &comment.Comment, - Position: position, - CommitID: &fileInfo.sha, - } - - // Set start line if provided - if comment.StartLine > 0 { - reviewComment.StartLine = &comment.StartLine - } - - // Create the comment with retries - var resp *github.Response - for i := 0; i < 6; i++ { - _, resp, err = g.client.PullRequests.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, reviewComment) - if err == nil { - break - } - if resp != nil && resp.StatusCode == 422 { - // Rate limit hit, wait and retry - retrySeconds := i * i - time.Sleep(time.Second * time.Duration(retrySeconds)) - continue - } - break - } - - if err != nil { - errorDetails := fmt.Sprintf("Error commenting on file %s, line %d: %s", comment.File, comment.Line, err.Error()) - if resp != nil { - errorDetails += fmt.Sprintf("\nResponse Status: %s", resp.Status) - if resp.Body != nil { - body, _ := io.ReadAll(resp.Body) - errorDetails += fmt.Sprintf("\nResponse Body: %s", string(body)) - } - } - results = append(results, errorDetails) - continue - } - - results = append(results, fmt.Sprintf("Successfully commented on file %s, line %d", comment.File, comment.Line)) - } - - if result.GeneralComment != "" { - // Try both PullRequests and Issues API for general comments - var resp *github.Response - var err error - - // First try PullRequests API - _, resp, err = g.client.PullRequests.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.PullRequestComment{ - Body: &result.GeneralComment, + // If that fails with 403, try Issues API + if err != nil && resp != nil && resp.StatusCode == 403 { + _, resp, err = g.client.Issues.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.IssueComment{ + Body: &result.Comment, }) + } - // If that fails with 403, try Issues API - if err != nil && resp != nil && resp.StatusCode == 403 { - _, resp, err = g.client.Issues.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.IssueComment{ - Body: &result.GeneralComment, - }) - } - - if err != nil { - errorDetails := fmt.Sprintf("Error adding general comment: %s", err.Error()) - if resp != nil { - errorDetails += fmt.Sprintf("\nResponse Status: %s", resp.Status) - if resp.Body != nil { - body, _ := io.ReadAll(resp.Body) - errorDetails += fmt.Sprintf("\nResponse Body: %s", string(body)) - } - } - results = append(results, errorDetails) - } else { - results = append(results, "Successfully added general comment to pull request") - } + if err != nil { + return types.ActionResult{Result: fmt.Sprintf("Error adding general comment: %s", err.Error())}, nil } return types.ActionResult{ - Result: strings.Join(results, "\n"), + Result: "Successfully added general comment to pull request", }, nil } @@ -305,38 +193,12 @@ func (g *GithubPRCommenter) Definition() types.ActionDefinition { Type: jsonschema.Number, Description: "The number of the pull request to comment on.", }, - "general_comment": { + "comment": { Type: jsonschema.String, Description: "A general comment to add to the pull request.", }, - "comments": { - Type: jsonschema.Array, - Items: &jsonschema.Definition{ - Type: jsonschema.Object, - Properties: map[string]jsonschema.Definition{ - "file": { - Type: jsonschema.String, - Description: "The file to comment on.", - }, - "line": { - Type: jsonschema.Number, - Description: "The line number to comment on.", - }, - "comment": { - Type: jsonschema.String, - Description: "The comment text.", - }, - "start_line": { - Type: jsonschema.Number, - Description: "Optional start line for multi-line comments.", - }, - }, - Required: []string{"file", "line", "comment"}, - }, - Description: "Array of comments to add to the pull request.", - }, }, - Required: []string{"pr_number", "comments"}, + Required: []string{"pr_number", "comment"}, } } return types.ActionDefinition{ @@ -355,38 +217,12 @@ func (g *GithubPRCommenter) Definition() types.ActionDefinition { Type: jsonschema.String, Description: "The owner of the repository.", }, - "general_comment": { + "comment": { Type: jsonschema.String, Description: "A general comment to add to the pull request.", }, - "comments": { - Type: jsonschema.Array, - Items: &jsonschema.Definition{ - Type: jsonschema.Object, - Properties: map[string]jsonschema.Definition{ - "file": { - Type: jsonschema.String, - Description: "The file to comment on.", - }, - "line": { - Type: jsonschema.Number, - Description: "The line number to comment on.", - }, - "comment": { - Type: jsonschema.String, - Description: "The comment text.", - }, - "start_line": { - Type: jsonschema.Number, - Description: "Optional start line for multi-line comments.", - }, - }, - Required: []string{"file", "line", "comment"}, - }, - Description: "Array of comments to add to the pull request.", - }, }, - Required: []string{"pr_number", "repository", "owner", "comments"}, + Required: []string{"pr_number", "repository", "owner", "comment"}, } } diff --git a/services/actions/githubprreviewer.go b/services/actions/githubprreviewer.go new file mode 100644 index 0000000..b6af58a --- /dev/null +++ b/services/actions/githubprreviewer.go @@ -0,0 +1,286 @@ +package actions + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/google/go-github/v69/github" + "github.com/mudler/LocalAGI/core/types" + "github.com/mudler/LocalAGI/pkg/config" + "github.com/mudler/LocalAGI/pkg/xlog" + "github.com/sashabaranov/go-openai/jsonschema" +) + +type GithubPRReviewer struct { + token, repository, owner, customActionName string + client *github.Client +} + +func NewGithubPRReviewer(config map[string]string) *GithubPRReviewer { + client := github.NewClient(nil).WithAuthToken(config["token"]) + + return &GithubPRReviewer{ + client: client, + token: config["token"], + customActionName: config["customActionName"], + repository: config["repository"], + owner: config["owner"], + } +} + +func (g *GithubPRReviewer) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) { + result := struct { + Repository string `json:"repository"` + Owner string `json:"owner"` + PRNumber int `json:"pr_number"` + ReviewComment string `json:"review_comment"` + ReviewAction string `json:"review_action"` // APPROVE, REQUEST_CHANGES, or COMMENT + Comments []struct { + File string `json:"file"` + Line int `json:"line"` + Comment string `json:"comment"` + StartLine int `json:"start_line,omitempty"` + } `json:"comments"` + }{} + err := params.Unmarshal(&result) + if err != nil { + return types.ActionResult{}, fmt.Errorf("failed to unmarshal params: %w", err) + } + + if g.repository != "" && g.owner != "" { + result.Repository = g.repository + result.Owner = g.owner + } + + // First verify the PR exists and is in a valid state + pr, _, err := g.client.PullRequests.Get(ctx, result.Owner, result.Repository, result.PRNumber) + if err != nil { + return types.ActionResult{}, fmt.Errorf("failed to fetch PR #%d: %w", result.PRNumber, err) + } + if pr == nil { + return types.ActionResult{Result: fmt.Sprintf("Pull request #%d not found in repository %s/%s", result.PRNumber, result.Owner, result.Repository)}, nil + } + + // Check if PR is in a state that allows reviews + if *pr.State != "open" { + return types.ActionResult{Result: fmt.Sprintf("Pull request #%d is not open (current state: %s)", result.PRNumber, *pr.State)}, nil + } + + // Get the list of changed files to verify the files exist in the PR + files, _, err := g.client.PullRequests.ListFiles(ctx, result.Owner, result.Repository, result.PRNumber, &github.ListOptions{}) + if err != nil { + return types.ActionResult{}, fmt.Errorf("failed to list PR files: %w", err) + } + + // Create a map of valid files + validFiles := make(map[string]bool) + for _, file := range files { + if *file.Status != "deleted" { + validFiles[*file.Filename] = true + } + } + + // Process each comment + var reviewComments []*github.DraftReviewComment + for _, comment := range result.Comments { + // Check if file exists in PR + if !validFiles[comment.File] { + continue + } + + reviewComment := &github.DraftReviewComment{ + Path: &comment.File, + Line: &comment.Line, + Body: &comment.Comment, + } + + // Set start line if provided + if comment.StartLine > 0 { + reviewComment.StartLine = &comment.StartLine + } + + reviewComments = append(reviewComments, reviewComment) + } + + // Create the review + review := &github.PullRequestReviewRequest{ + Event: &result.ReviewAction, + Body: &result.ReviewComment, + Comments: reviewComments, + } + + xlog.Debug("[githubprreviewer] review", "review", review) + + // Submit the review + _, resp, err := g.client.PullRequests.CreateReview(ctx, result.Owner, result.Repository, result.PRNumber, review) + if err != nil { + errorDetails := fmt.Sprintf("Error submitting review: %s", err.Error()) + if resp != nil { + errorDetails += fmt.Sprintf("\nResponse Status: %s", resp.Status) + if resp.Body != nil { + body, _ := io.ReadAll(resp.Body) + errorDetails += fmt.Sprintf("\nResponse Body: %s", string(body)) + } + } + return types.ActionResult{Result: errorDetails}, err + } + + actionResult := strings.Builder{} + + actionResult.WriteString("========================== PR Reviewer Result ==========================\n") + actionResult.WriteString(fmt.Sprintf("Pull request https://github.com/%s/%s/pull/%d", result.Owner, result.Repository, result.PRNumber)) + actionResult.WriteString("\n") + actionResult.WriteString(fmt.Sprintf("Successfully reviewed with status: %s", strings.ToLower(result.ReviewAction))) + actionResult.WriteString("\n") + + return types.ActionResult{Result: actionResult.String()}, nil +} + +func (g *GithubPRReviewer) Definition() types.ActionDefinition { + actionName := "review_github_pr" + if g.customActionName != "" { + actionName = g.customActionName + } + description := "Review a GitHub pull request by approving, requesting changes, or commenting." + if g.repository != "" && g.owner != "" { + return types.ActionDefinition{ + Name: types.ActionDefinitionName(actionName), + Description: description, + Properties: map[string]jsonschema.Definition{ + "pr_number": { + Type: jsonschema.Number, + Description: "The number of the pull request to review.", + }, + "review_comment": { + Type: jsonschema.String, + Description: "The main review comment to add to the pull request.", + }, + "review_action": { + Type: jsonschema.String, + Description: "The type of review to submit (APPROVE, REQUEST_CHANGES, or COMMENT).", + Enum: []string{"APPROVE", "REQUEST_CHANGES", "COMMENT"}, + }, + "comments": { + Type: jsonschema.Array, + Items: &jsonschema.Definition{ + Type: jsonschema.Object, + Properties: map[string]jsonschema.Definition{ + "file": { + Type: jsonschema.String, + Description: "The file to comment on.", + }, + "line": { + Type: jsonschema.Number, + Description: "The line number to comment on.", + }, + "comment": { + Type: jsonschema.String, + Description: "The comment text.", + }, + "start_line": { + Type: jsonschema.Number, + Description: "Optional start line for multi-line comments.", + }, + }, + Required: []string{"file", "line", "comment"}, + }, + Description: "Array of line-specific comments to add to the review.", + }, + }, + Required: []string{"pr_number", "review_action"}, + } + } + return types.ActionDefinition{ + Name: types.ActionDefinitionName(actionName), + Description: description, + Properties: map[string]jsonschema.Definition{ + "pr_number": { + Type: jsonschema.Number, + Description: "The number of the pull request to review.", + }, + "repository": { + Type: jsonschema.String, + Description: "The repository containing the pull request.", + }, + "owner": { + Type: jsonschema.String, + Description: "The owner of the repository.", + }, + "review_comment": { + Type: jsonschema.String, + Description: "The main review comment to add to the pull request.", + }, + "review_action": { + Type: jsonschema.String, + Description: "The type of review to submit (APPROVE, REQUEST_CHANGES, or COMMENT).", + Enum: []string{"APPROVE", "REQUEST_CHANGES", "COMMENT"}, + }, + "comments": { + Type: jsonschema.Array, + Items: &jsonschema.Definition{ + Type: jsonschema.Object, + Properties: map[string]jsonschema.Definition{ + "file": { + Type: jsonschema.String, + Description: "The file to comment on.", + }, + "line": { + Type: jsonschema.Number, + Description: "The line number to comment on.", + }, + "comment": { + Type: jsonschema.String, + Description: "The comment text.", + }, + "start_line": { + Type: jsonschema.Number, + Description: "Optional start line for multi-line comments.", + }, + }, + Required: []string{"file", "line", "comment"}, + }, + Description: "Array of line-specific comments to add to the review.", + }, + }, + Required: []string{"pr_number", "repository", "owner", "review_action"}, + } +} + +func (a *GithubPRReviewer) Plannable() bool { + return true +} + +// GithubPRReviewerConfigMeta returns the metadata for GitHub PR Reviewer action configuration fields +func GithubPRReviewerConfigMeta() []config.Field { + return []config.Field{ + { + Name: "token", + Label: "GitHub Token", + Type: config.FieldTypeText, + Required: true, + HelpText: "GitHub API token with repository access", + }, + { + Name: "repository", + Label: "Repository", + Type: config.FieldTypeText, + Required: false, + HelpText: "GitHub repository name", + }, + { + Name: "owner", + Label: "Owner", + Type: config.FieldTypeText, + Required: false, + HelpText: "GitHub repository owner", + }, + { + Name: "customActionName", + Label: "Custom Action Name", + Type: config.FieldTypeText, + HelpText: "Custom name for this action", + }, + } +}