Compare commits

..

5 Commits

Author SHA1 Message Date
mudler
e4271b4d2f feat: improve action picking
Signed-off-by: mudler <mudler@localai.io>
2025-04-11 20:43:48 +02:00
Ettore Di Giacinto
9dad2b0ba4 Add Github reviewer and improve reasoning 2025-04-10 23:36:44 +02:00
Ettore Di Giacinto
e4c7d1acfc feat(github): add actions to comment and read PRs (#26)
Signed-off-by: mudler <mudler@localai.io>
2025-04-10 21:45:18 +02:00
Ettore Di Giacinto
dd4fbd64d3 fix(pick_action): improve action pickup by using only the assistant thought process (#25)
* fix(pick_action): improve action pickup by using only the assistant thought process

Signed-off-by: mudler <mudler@localai.io>

* fix: improve templates

Signed-off-by: mudler <mudler@localai.io>

---------

Signed-off-by: mudler <mudler@localai.io>
2025-04-10 21:45:04 +02:00
Ettore Di Giacinto
4010f9d86c Update README.md 2025-04-10 19:38:13 +02:00
9 changed files with 956 additions and 121 deletions

View File

@@ -52,7 +52,7 @@ docker compose up -f docker-compose.yml
docker compose up -f docker-compose.gpu.yml docker compose up -f docker-compose.gpu.yml
``` ```
Access your agents at `http://localhost:3000` Access your agents at `http://localhost:8080`
## 🏆 Why Choose LocalAGI? ## 🏆 Why Choose LocalAGI?

49
core/action/goal.go Normal file
View File

@@ -0,0 +1,49 @@
package action
import (
"context"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai/jsonschema"
)
// NewGoal creates a new intention action
// The inention action is special as it tries to identify
// a tool to use and a reasoning over to use it
func NewGoal(s ...string) *GoalAction {
return &GoalAction{tools: s}
}
type GoalAction struct {
tools []string
}
type GoalResponse struct {
Goal string `json:"goal"`
Achieved bool `json:"achieved"`
}
func (a *GoalAction) Run(context.Context, types.ActionParams) (types.ActionResult, error) {
return types.ActionResult{}, nil
}
func (a *GoalAction) Plannable() bool {
return false
}
func (a *GoalAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: "goal",
Description: "Check if the goal is achieved",
Properties: map[string]jsonschema.Definition{
"goal": {
Type: jsonschema.String,
Description: "The goal to check if it is achieved.",
},
"achieved": {
Type: jsonschema.Boolean,
Description: "Whether the goal is achieved",
},
},
Required: []string{"goal", "achieved"},
}
}

View File

@@ -79,6 +79,15 @@ func (m Messages) ToOpenAI() []openai.ChatCompletionMessage {
return []openai.ChatCompletionMessage(m) return []openai.ChatCompletionMessage(m)
} }
func (m Messages) RemoveIf(f func(msg openai.ChatCompletionMessage) bool) Messages {
for i := len(m) - 1; i >= 0; i-- {
if f(m[i]) {
m = append(m[:i], m[i+1:]...)
}
}
return m
}
func (m Messages) String() string { func (m Messages) String() string {
s := "" s := ""
for _, cc := range m { for _, cc := range m {
@@ -358,7 +367,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) { func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.ChatCompletionMessage, maxRetries int) (types.Action, types.ActionParams, string, error) {
c := messages c := messages
xlog.Debug("picking action", "messages", messages) xlog.Debug("[pickAction] picking action", "messages", messages)
if !a.options.forceReasoning { if !a.options.forceReasoning {
xlog.Debug("not forcing reasoning") xlog.Debug("not forcing reasoning")
@@ -389,7 +398,7 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.
return chosenAction, thought.actionParams, thought.message, nil return chosenAction, thought.actionParams, thought.message, nil
} }
xlog.Debug("forcing reasoning") xlog.Debug("[pickAction] forcing reasoning")
prompt, err := renderTemplate(templ, a.prepareHUD(), a.availableActions(), "") prompt, err := renderTemplate(templ, a.prepareHUD(), a.availableActions(), "")
if err != nil { if err != nil {
@@ -406,71 +415,121 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.
}, c...) }, 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{} actionsID := []string{}
for _, m := range a.availableActions() { for _, m := range a.availableActions() {
actionsID = append(actionsID, m.Definition().Name.String()) actionsID = append(actionsID, m.Definition().Name.String())
} }
intentionsTools := action.NewIntention(actionsID...)
// NOTE: we do not give the full conversation here to pick the action // thoughtPromptStringBuilder := strings.Builder{}
// to avoid hallucinations // 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).RemoveIf(func(msg openai.ChatCompletionMessage) bool {
// return msg.Role == "system"
//}).String())
//thoughtPrompt := thoughtPromptStringBuilder.String()
//thoughtConv := []openai.ChatCompletionMessage{}
thought, err := a.askLLM(ctx,
c,
maxRetries,
)
if err != nil {
return nil, nil, "", err
}
originalReasoning := thought.Content
// From the thought, get the action call
// Get all the available actions IDs
// by grammar, let's decide if we have achieved the goal
// 1. analyze response and check if goal is achieved
params, err := a.decision(ctx, params, err := a.decision(ctx,
[]openai.ChatCompletionMessage{{ []openai.ChatCompletionMessage{
Role: "assistant", {
Content: reason, Role: "system",
}, Content: "Extract an action to perform from the following reasoning: ",
}, },
types.Actions{intentionsTools}.ToTools(), {
intentionsTools.Definition().Name, maxRetries) Role: "user",
Content: originalReasoning,
}},
types.Actions{action.NewGoal()}.ToTools(),
action.NewGoal().Definition().Name, maxRetries)
if err != nil { if err != nil {
return nil, nil, "", fmt.Errorf("failed to get the action tool parameters: %v", err) return nil, nil, "", fmt.Errorf("failed to get the action tool parameters: %v", err)
} }
actionChoice := action.IntentResponse{} goalResponse := action.GoalResponse{}
err = params.actionParams.Unmarshal(&goalResponse)
if params.actionParams == nil {
return nil, nil, params.message, nil
}
err = params.actionParams.Unmarshal(&actionChoice)
if err != nil { if err != nil {
return nil, nil, "", err return nil, nil, "", err
} }
if actionChoice.Tool == "" || actionChoice.Tool == "none" { if goalResponse.Achieved {
return nil, nil, "", fmt.Errorf("no intent detected") xlog.Debug("[pickAction] goal achieved", "goal", goalResponse.Goal)
return nil, nil, "", nil
} }
// Find the action // if the goal is not achieved, pick an action
chosenAction := a.availableActions().Find(actionChoice.Tool) xlog.Debug("[pickAction] goal not achieved", "goal", goalResponse.Goal)
if chosenAction == nil {
return nil, nil, "", fmt.Errorf("no action found for intent:" + actionChoice.Tool) xlog.Debug("[pickAction] thought", "conv", c, "originalReasoning", originalReasoning)
// TODO: FORCE to select ana ction here
// NOTE: we do not give the full conversation here to pick the action
// to avoid hallucinations
params, err = a.decision(ctx,
[]openai.ChatCompletionMessage{
{
Role: "system",
Content: "Extract an action to perform from the following reasoning: ",
},
{
Role: "user",
Content: originalReasoning,
}},
a.availableActions().ToTools(),
nil, maxRetries)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to get the action tool parameters: %v", err)
} }
return chosenAction, nil, actionChoice.Reasoning, nil chosenAction := a.availableActions().Find(params.actioName)
// xlog.Debug("[pickAction] params", "params", params)
// if params.actionParams == nil {
// return nil, nil, params.message, nil
// }
// xlog.Debug("[pickAction] actionChoice", "actionChoice", params.actionParams, "message", params.message)
// actionChoice := action.IntentResponse{}
// err = params.actionParams.Unmarshal(&actionChoice)
// if err != nil {
// return nil, nil, "", err
// }
// if actionChoice.Tool == "" || actionChoice.Tool == "none" {
// return nil, nil, "", nil
// }
// // Find the action
// chosenAction := a.availableActions().Find(actionChoice.Tool)
// if chosenAction == nil {
// return nil, nil, "", fmt.Errorf("no action found for intent:" + actionChoice.Tool)
// }
return chosenAction, nil, originalReasoning, nil
} }

View File

@@ -515,10 +515,21 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
//job.Result.Finish(fmt.Errorf("no action to do"))\ //job.Result.Finish(fmt.Errorf("no action to do"))\
xlog.Info("No action to do, just reply", "agent", a.Character.Name, "reasoning", reasoning) xlog.Info("No action to do, just reply", "agent", a.Character.Name, "reasoning", reasoning)
conv = append(conv, openai.ChatCompletionMessage{ if reasoning != "" {
Role: "assistant", conv = append(conv, openai.ChatCompletionMessage{
Content: reasoning, Role: "assistant",
}) Content: reasoning,
})
} else {
xlog.Info("No reasoning, just reply", "agent", a.Character.Name)
msg, err := a.askLLM(job.GetContext(), conv, maxRetries)
if err != nil {
job.Result.Finish(fmt.Errorf("error asking LLM for a reply: %w", err))
return
}
conv = append(conv, msg)
reasoning = msg.Content
}
xlog.Debug("Finish job with reasoning", "reasoning", reasoning, "agent", a.Character.Name, "conversation", fmt.Sprintf("%+v", conv)) xlog.Debug("Finish job with reasoning", "reasoning", reasoning, "agent", a.Character.Name, "conversation", fmt.Sprintf("%+v", conv))
job.Result.Conversation = conv job.Result.Conversation = conv
@@ -670,6 +681,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
!chosenAction.Definition().Name.Is(action.ReplyActionName) { !chosenAction.Definition().Name.Is(action.ReplyActionName) {
xlog.Info("Following action", "action", followingAction.Definition().Name, "agent", a.Character.Name) xlog.Info("Following action", "action", followingAction.Definition().Name, "agent", a.Character.Name)
job.ConversationHistory = conv
// We need to do another action (?) // We need to do another action (?)
// The agent decided to do another action // The agent decided to do another action

View File

@@ -82,11 +82,7 @@ Current State:
- Short-term Memory: {{range .CurrentState.Memories}}{{.}} {{end}}{{end}} - Short-term Memory: {{range .CurrentState.Memories}}{{.}} {{end}}{{end}}
Current Time: {{.Time}}` Current Time: {{.Time}}`
const pickSelfTemplate = `Available Tools: const pickSelfTemplate = `
{{range .Actions -}}
- {{.Name}}: {{.Description }}
{{ end }}
You are an autonomous AI agent with a defined character and state (as shown above). You are an autonomous AI agent with a defined character and state (as shown above).
Your task is to evaluate your current situation and determine the best course of action. Your task is to evaluate your current situation and determine the best course of action.
@@ -108,40 +104,21 @@ Remember:
- Keep track of your progress and state - Keep track of your progress and state
- Be proactive in addressing potential issues - Be proactive in addressing potential issues
{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}
` + hudTemplate
const reSelfEvalTemplate = pickSelfTemplate + `
Previous actions have been executed. Evaluate the current situation:
1. Review the outcomes of previous actions
2. Assess progress toward your goals
3. Identify any issues or challenges
4. Determine if additional actions are needed
Consider:
- Success of previous actions
- Changes in the situation
- New information or insights
- Potential next steps
Make a decision about whether to:
- Continue with more actions
- Provide a final response
- Adjust your approach
- Update your goals or state`
const pickActionTemplate = hudTemplate + `
Available Tools: Available Tools:
{{range .Actions -}} {{range .Actions -}}
- {{.Name}}: {{.Description }} - {{.Name}}: {{.Description }}
{{ end }} {{ end }}
Task: Analyze the situation and determine the best course of action. {{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}
` + hudTemplate
const reSelfEvalTemplate = pickSelfTemplate
const pickActionTemplate = hudTemplate + `
Your only task is to analyze the situation and determine a goal and the best tool to use, or just a final response if we have fullfilled the goal.
Guidelines: Guidelines:
1. Review the current state and context 1. Review the current state, what was done already and context
2. Consider available tools and their purposes 2. Consider available tools and their purposes
3. Plan your approach carefully 3. Plan your approach carefully
4. Explain your reasoning clearly 4. Explain your reasoning clearly
@@ -159,38 +136,11 @@ Decision Process:
4. Explain your reasoning 4. Explain your reasoning
5. Execute the chosen action 5. Execute the chosen action
Available Tools:
{{range .Actions -}}
- {{.Name}}: {{.Description }}
{{ end }}
{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}` {{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}`
const reEvalTemplate = pickActionTemplate + ` const reEvalTemplate = pickActionTemplate
Previous actions have been executed. Let's evaluate the current situation:
1. Review Previous Actions:
- What actions were taken
- What were the results
- Any issues or challenges encountered
2. Assess Current State:
- Progress toward goals
- Changes in the situation
- New information or insights
- Current challenges or opportunities
3. Determine Next Steps:
- Additional tools needed
- Final response required
- Error handling needed
- Approach adjustments required
4. Decision Making:
- If task is complete: Use "reply" tool
- If errors exist: Address them appropriately
- If more actions needed: Explain why and which tools
- If situation changed: Adapt your approach
Remember to:
- Consider all available information
- Be specific about next steps
- Explain your reasoning clearly
- Handle errors appropriately
- Provide complete responses when done`

View File

@@ -26,6 +26,9 @@ const (
ActionGithubRepositoryCreateOrUpdate = "github-repository-create-or-update-content" ActionGithubRepositoryCreateOrUpdate = "github-repository-create-or-update-content"
ActionGithubIssueReader = "github-issue-reader" ActionGithubIssueReader = "github-issue-reader"
ActionGithubIssueCommenter = "github-issue-commenter" ActionGithubIssueCommenter = "github-issue-commenter"
ActionGithubPRReader = "github-pr-reader"
ActionGithubPRCommenter = "github-pr-commenter"
ActionGithubPRReviewer = "github-pr-reviewer"
ActionGithubREADME = "github-readme" ActionGithubREADME = "github-readme"
ActionScraper = "scraper" ActionScraper = "scraper"
ActionWikipedia = "wikipedia" ActionWikipedia = "wikipedia"
@@ -49,6 +52,9 @@ var AvailableActions = []string{
ActionGithubRepositoryCreateOrUpdate, ActionGithubRepositoryCreateOrUpdate,
ActionGithubIssueReader, ActionGithubIssueReader,
ActionGithubIssueCommenter, ActionGithubIssueCommenter,
ActionGithubPRReader,
ActionGithubPRCommenter,
ActionGithubPRReviewer,
ActionGithubREADME, ActionGithubREADME,
ActionScraper, ActionScraper,
ActionBrowse, ActionBrowse,
@@ -106,6 +112,12 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
a = actions.NewGithubIssueSearch(config) a = actions.NewGithubIssueSearch(config)
case ActionGithubIssueReader: case ActionGithubIssueReader:
a = actions.NewGithubIssueReader(config) a = actions.NewGithubIssueReader(config)
case ActionGithubPRReader:
a = actions.NewGithubPRReader(config)
case ActionGithubPRCommenter:
a = actions.NewGithubPRCommenter(config)
case ActionGithubPRReviewer:
a = actions.NewGithubPRReviewer(config)
case ActionGithubIssueCommenter: case ActionGithubIssueCommenter:
a = actions.NewGithubIssueCommenter(config) a = actions.NewGithubIssueCommenter(config)
case ActionGithubRepositoryGet: case ActionGithubRepositoryGet:
@@ -199,6 +211,21 @@ func ActionsConfigMeta() []config.FieldGroup {
Label: "GitHub Repository README", Label: "GitHub Repository README",
Fields: actions.GithubRepositoryREADMEConfigMeta(), Fields: actions.GithubRepositoryREADMEConfigMeta(),
}, },
{
Name: "github-pr-reader",
Label: "GitHub PR Reader",
Fields: actions.GithubPRReaderConfigMeta(),
},
{
Name: "github-pr-commenter",
Label: "GitHub PR Commenter",
Fields: actions.GithubPRCommenterConfigMeta(),
},
{
Name: "github-pr-reviewer",
Label: "GitHub PR Reviewer",
Fields: actions.GithubPRReviewerConfigMeta(),
},
{ {
Name: "twitter-post", Name: "twitter-post",
Label: "Twitter Post", Label: "Twitter Post",

View File

@@ -0,0 +1,264 @@
package actions
import (
"context"
"fmt"
"regexp"
"strconv"
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/sashabaranov/go-openai/jsonschema"
)
type GithubPRCommenter struct {
token, repository, owner, customActionName string
client *github.Client
}
var (
patchRegex = regexp.MustCompile(`^@@.*\d [\+\-](\d+),?(\d+)?.+?@@`)
)
type commitFileInfo struct {
FileName string
hunkInfos []*hunkInfo
sha string
}
type hunkInfo struct {
hunkStart int
hunkEnd int
}
func (hi hunkInfo) isLineInHunk(line int) bool {
return line >= hi.hunkStart && line <= hi.hunkEnd
}
func (cfi *commitFileInfo) getHunkInfo(line int) *hunkInfo {
for _, hunkInfo := range cfi.hunkInfos {
if hunkInfo.isLineInHunk(line) {
return hunkInfo
}
}
return nil
}
func (cfi *commitFileInfo) isLineInChange(line int) bool {
return cfi.getHunkInfo(line) != nil
}
func (cfi commitFileInfo) calculatePosition(line int) *int {
hi := cfi.getHunkInfo(line)
if hi == nil {
return nil
}
position := line - hi.hunkStart
return &position
}
func parseHunkPositions(patch, filename string) ([]*hunkInfo, error) {
hunkInfos := make([]*hunkInfo, 0)
if patch != "" {
groups := patchRegex.FindAllStringSubmatch(patch, -1)
if len(groups) < 1 {
return hunkInfos, fmt.Errorf("the patch details for [%s] could not be resolved", filename)
}
for _, patchGroup := range groups {
endPos := 2
if len(patchGroup) > 2 && patchGroup[2] == "" {
endPos = 1
}
hunkStart, err := strconv.Atoi(patchGroup[1])
if err != nil {
hunkStart = -1
}
hunkEnd, err := strconv.Atoi(patchGroup[endPos])
if err != nil {
hunkEnd = -1
}
hunkInfos = append(hunkInfos, &hunkInfo{
hunkStart: hunkStart,
hunkEnd: hunkEnd,
})
}
}
return hunkInfos, nil
}
func getCommitInfo(file *github.CommitFile) (*commitFileInfo, error) {
patch := file.GetPatch()
hunkInfos, err := parseHunkPositions(patch, *file.Filename)
if err != nil {
return nil, err
}
sha := file.GetSHA()
if sha == "" {
return nil, fmt.Errorf("the sha details for [%s] could not be resolved", *file.Filename)
}
return &commitFileInfo{
FileName: *file.Filename,
hunkInfos: hunkInfos,
sha: sha,
}, nil
}
func NewGithubPRCommenter(config map[string]string) *GithubPRCommenter {
client := github.NewClient(nil).WithAuthToken(config["token"])
return &GithubPRCommenter{
client: client,
token: config["token"],
customActionName: config["customActionName"],
repository: config["repository"],
owner: config["owner"],
}
}
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"`
Comment string `json:"comment"`
}{}
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 comments
if *pr.State != "open" {
return types.ActionResult{Result: fmt.Sprintf("Pull request #%d is not open (current state: %s)", result.PRNumber, *pr.State)}, nil
}
if result.Comment == "" {
return types.ActionResult{Result: "No comment provided"}, nil
}
// Try both PullRequests and Issues API for general comments
var resp *github.Response
// First try PullRequests API
_, resp, err = g.client.PullRequests.CreateComment(ctx, result.Owner, result.Repository, result.PRNumber, &github.PullRequestComment{
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.Comment,
})
}
if err != nil {
return types.ActionResult{Result: fmt.Sprintf("Error adding general comment: %s", err.Error())}, nil
}
return types.ActionResult{
Result: "Successfully added general comment to pull request",
}, nil
}
func (g *GithubPRCommenter) Definition() types.ActionDefinition {
actionName := "comment_github_pr"
if g.customActionName != "" {
actionName = g.customActionName
}
description := "Add comments to a GitHub pull request, including line-specific feedback. Often used after reading a PR to provide a peer review."
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 comment on.",
},
"comment": {
Type: jsonschema.String,
Description: "A general comment to add to the pull request.",
},
},
Required: []string{"pr_number", "comment"},
}
}
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 comment on.",
},
"repository": {
Type: jsonschema.String,
Description: "The repository containing the pull request.",
},
"owner": {
Type: jsonschema.String,
Description: "The owner of the repository.",
},
"comment": {
Type: jsonschema.String,
Description: "A general comment to add to the pull request.",
},
},
Required: []string{"pr_number", "repository", "owner", "comment"},
}
}
func (a *GithubPRCommenter) Plannable() bool {
return true
}
// GithubPRCommenterConfigMeta returns the metadata for GitHub PR Commenter action configuration fields
func GithubPRCommenterConfigMeta() []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",
},
}
}

View File

@@ -0,0 +1,188 @@
package actions
import (
"context"
"fmt"
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/sashabaranov/go-openai/jsonschema"
)
type GithubPRReader struct {
token, repository, owner, customActionName string
showFullDiff bool
client *github.Client
}
func NewGithubPRReader(config map[string]string) *GithubPRReader {
client := github.NewClient(nil).WithAuthToken(config["token"])
showFullDiff := false
if config["showFullDiff"] == "true" {
showFullDiff = true
}
return &GithubPRReader{
client: client,
token: config["token"],
customActionName: config["customActionName"],
repository: config["repository"],
owner: config["owner"],
showFullDiff: showFullDiff,
}
}
func (g *GithubPRReader) 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"`
}{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, err
}
if g.repository != "" && g.owner != "" {
result.Repository = g.repository
result.Owner = g.owner
}
pr, _, err := g.client.PullRequests.Get(ctx, result.Owner, result.Repository, result.PRNumber)
if err != nil {
return types.ActionResult{Result: fmt.Sprintf("Error fetching pull request: %s", err.Error())}, err
}
if pr == nil {
return types.ActionResult{Result: fmt.Sprintf("No pull request found")}, nil
}
// Get the list of changed files
files, _, err := g.client.PullRequests.ListFiles(ctx, result.Owner, result.Repository, result.PRNumber, &github.ListOptions{})
if err != nil {
return types.ActionResult{Result: fmt.Sprintf("Error fetching pull request files: %s", err.Error())}, err
}
// Get CI status information
ciStatus := "\n\nCI Status:\n"
// Get PR status checks
checkRuns, _, err := g.client.Checks.ListCheckRunsForRef(ctx, result.Owner, result.Repository, pr.GetHead().GetSHA(), &github.ListCheckRunsOptions{})
if err == nil && checkRuns != nil {
ciStatus += fmt.Sprintf("\nPR Status Checks:\n")
ciStatus += fmt.Sprintf("Total Checks: %d\n", checkRuns.GetTotal())
for _, check := range checkRuns.CheckRuns {
ciStatus += fmt.Sprintf("- %s: %s (%s)\n",
check.GetName(),
check.GetConclusion(),
check.GetStatus())
}
}
// Build the file changes summary with patches
fileChanges := "\n\nFile Changes:\n"
for _, file := range files {
fileChanges += fmt.Sprintf("\n--- %s\n+++ %s\n", file.GetFilename(), file.GetFilename())
if g.showFullDiff && file.GetPatch() != "" {
fileChanges += file.GetPatch()
}
fileChanges += fmt.Sprintf("\n(%d additions, %d deletions)\n", file.GetAdditions(), file.GetDeletions())
}
return types.ActionResult{
Result: fmt.Sprintf(
"Pull Request %d Repository: %s\nTitle: %s\nBody: %s\nState: %s\nBase: %s\nHead: %s%s%s",
pr.GetNumber(),
pr.GetBase().GetRepo().GetFullName(),
pr.GetTitle(),
pr.GetBody(),
pr.GetState(),
pr.GetBase().GetRef(),
pr.GetHead().GetRef(),
ciStatus,
fileChanges)}, nil
}
func (g *GithubPRReader) Definition() types.ActionDefinition {
actionName := "read_github_pr"
if g.customActionName != "" {
actionName = g.customActionName
}
description := "Read a GitHub pull request."
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 read.",
},
},
Required: []string{"pr_number"},
}
}
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 read.",
},
"repository": {
Type: jsonschema.String,
Description: "The repository containing the pull request.",
},
"owner": {
Type: jsonschema.String,
Description: "The owner of the repository.",
},
},
Required: []string{"pr_number", "repository", "owner"},
}
}
func (a *GithubPRReader) Plannable() bool {
return true
}
// GithubPRReaderConfigMeta returns the metadata for GitHub PR Reader action configuration fields
func GithubPRReaderConfigMeta() []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",
},
{
Name: "showFullDiff",
Label: "Show Full Diff",
Type: config.FieldTypeCheckbox,
HelpText: "Whether to show the full diff content or just the summary",
},
}
}

View File

@@ -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 := fmt.Sprintf(
"Pull request https://github.com/%s/%s/pull/%d reviewed successfully with status: %s",
result.Owner,
result.Repository,
result.PRNumber,
strings.ToLower(result.ReviewAction),
)
return types.ActionResult{Result: actionResult}, 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",
},
}
}