mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
feat: init
This commit is contained in:
commit
484121b92b
19 changed files with 1789 additions and 0 deletions
29
Makefile
Normal file
29
Makefile
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
.PHONY: build test clean run help
|
||||
|
||||
BINARY_NAME := argus
|
||||
GO := go
|
||||
BUILD_DIR := ./bin
|
||||
|
||||
build:
|
||||
$(GO) build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/argus
|
||||
|
||||
test:
|
||||
$(GO) test -v -race -count=1 ./...
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
run: build
|
||||
$(BUILD_DIR)/$(BINARY_NAME) --staged
|
||||
|
||||
help:
|
||||
$(BUILD_DIR)/$(BINARY_NAME) -h
|
||||
|
||||
# Cross-platform builds
|
||||
build-linux:
|
||||
GOOS=linux GOARCH=amd64 $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/argus
|
||||
|
||||
build-darwin:
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/argus
|
||||
|
||||
all: build-linux build-darwin
|
||||
120
README.md
Normal file
120
README.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Argus
|
||||
|
||||
An open-source, AI-powered code review CLI tool. Argus reads git diffs, analyzes changes through configurable LLM services (OpenAI, Claude, local models), and generates structured code review comments — without depending on any specific code platform.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ argus CLI │
|
||||
├──────────┬──────────────┬───────────────────────┤
|
||||
│ Git │ Agent Core │ Tool Registry │
|
||||
│ Diff │ (Plan + │ (file.read, │
|
||||
│ Parser │ Main Loop) │ file.search, etc.) │
|
||||
└────┬─────┴──────┬───────┴───────┬───────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────┐ ┌──────────┐ ┌──────────────┐
|
||||
│ git │ │ LLM │ │ User-defined │
|
||||
│ diff │ │ Client │ │ Providers │
|
||||
└──────┘ │(OpenAI- │ └──────────────┘
|
||||
│ comp.) │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Platform-agnostic**: Works with any git repository — no GitHub/GitLab/Lark dependencies required
|
||||
2. **Bring your own LLM**: Configure any OpenAI-compatible endpoint (Anthropic, OpenAI, Ollama, vLLM, etc.)
|
||||
3. **Extensible tools**: Register your own implementations for file reading, code search, comment submission, etc.
|
||||
4. **Security-first**: All data stays within your infrastructure when using self-hosted LLMs
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Build
|
||||
make build
|
||||
|
||||
# 2. Create config
|
||||
cp internal/config/example_config.yaml ./argus.yaml
|
||||
# Edit model names, prompts, thresholds...
|
||||
|
||||
# 3. Run review on staged changes
|
||||
./bin/argus --staged \
|
||||
--llm-url "https://api.openai.com/v1" \
|
||||
--llm-api-key "$OPENAI_API_KEY" \
|
||||
--llm-model "gpt-4o"
|
||||
|
||||
# 4. Or review a ref range
|
||||
./bin/argus --base main --head feature-branch \
|
||||
--config argus.yaml
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Flag | Env Var | Description |
|
||||
|------|---------|-------------|
|
||||
| `--config` | — | Path to YAML template (prompts, thresholds) |
|
||||
| `--repo` | — | Git repo root (default: current directory) |
|
||||
| `--staged` | — | Review staged changes |
|
||||
| `--base` | — | Base ref for diff range |
|
||||
| `--head` | — | Head ref for diff range |
|
||||
| `--llm-url` | `ARGUS_LLM_BASE_URL` | OpenAI-compatible API endpoint |
|
||||
| `--llm-api-key` | `ARGUS_LLM_API_KEY` | Bearer token |
|
||||
| `--llm-model` | `ARGUS_LLM_MODEL` | Model name override |
|
||||
| `--format` | — | Output format: `text` or `json` |
|
||||
| `--dry-run` | — | Run without submitting comments |
|
||||
| `--concurrency` | — | Max concurrent file reviews |
|
||||
| `--timeout` | — | Per-file timeout (minutes) |
|
||||
|
||||
## Extending Tools
|
||||
|
||||
Argus defines six extension points via the `tool.Provider` interface:
|
||||
|
||||
| Tool | Alias | Purpose |
|
||||
|------|-------|---------|
|
||||
| `FileRead` | `file_read` | Read file content at path/line range |
|
||||
| `FileFind` | `file_find` | Find files by glob pattern |
|
||||
| `FileReadDiff` | `file_read_diff` | Get diff between refs |
|
||||
| `FileSearch` | `file_search` | Search text within files (grep-like) |
|
||||
| `CodeSearch` | `code_search` | Semantic/symbol-aware code search |
|
||||
| `CodeComment` | `code_comment` | Submit review comments to platform |
|
||||
|
||||
See `internal/config/example_tools.go` for stub implementations showing how to wire up each one. Register them in `buildToolRegistry()` in `cmd/argus/main.go`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Argus/
|
||||
├── cmd/argus/
|
||||
│ ├── main.go # Entry point & orchestration
|
||||
│ ├── flags.go # CLI flag parsing
|
||||
│ ├── git.go # Git command helpers
|
||||
│ └── output.go # Text/JSON output formatters
|
||||
├── internal/
|
||||
│ ├── agent/
|
||||
│ │ └── agent.go # Core review agent (Plan + Main Loop)
|
||||
│ ├── config/
|
||||
│ │ ├── template.go # Template YAML struct definitions
|
||||
│ │ ├── example_config.yaml # Sample configuration
|
||||
│ │ └── example_tools.go # Tool implementation stubs
|
||||
│ ├── diff/
|
||||
│ │ ├── parser.go # Unified diff parser
|
||||
│ │ └── git.go # Git diff execution helpers
|
||||
│ ├── llm/
|
||||
│ │ └── client.go # OpenAI-compatible LLM client
|
||||
│ ├── model/
|
||||
│ │ └── diff.go # Data models (Diff, LlmComment, etc.)
|
||||
│ └── tool/
|
||||
│ ├── definitions.go # Tool enum + Provider interface + Registry
|
||||
│ ├── response_message.go # Checkpoint + result types
|
||||
│ └── stub.go # Stub/builtin providers
|
||||
├── pkg/ # Public API (future)
|
||||
├── Makefile
|
||||
├── go.mod
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
109
cmd/argus/flags.go
Normal file
109
cmd/argus/flags.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type cliOptions struct {
|
||||
configPath string
|
||||
repoDir string
|
||||
baseRef string
|
||||
headRef string
|
||||
staged bool
|
||||
llmBaseURL string
|
||||
llmAPIKey string
|
||||
llmModel string
|
||||
llmTimeout time.Duration
|
||||
concurrency int
|
||||
perFileTimeout int
|
||||
dryRun bool
|
||||
outputFormat string
|
||||
showHelp bool
|
||||
}
|
||||
|
||||
func parseFlags() (cliOptions, error) {
|
||||
// Quick check for -h before full parsing
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "-h" || arg == "--help" {
|
||||
return cliOptions{showHelp: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("argus", flag.ContinueOnError)
|
||||
|
||||
opts := cliOptions{}
|
||||
|
||||
fs.StringVar(&opts.configPath, "config", "argus.yaml", "path to YAML config file")
|
||||
fs.StringVar(&opts.repoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
||||
fs.StringVar(&opts.baseRef, "base", "", "base ref for diff range (e.g., 'main')")
|
||||
fs.StringVar(&opts.headRef, "head", "", "head ref for diff range (e.g., 'feature-branch')")
|
||||
fs.BoolVar(&opts.staged, "staged", false, "review staged changes instead of base..head")
|
||||
fs.StringVar(&opts.outputFormat, "format", "text", "output format: text or json")
|
||||
fs.BoolVar(&opts.dryRun, "dry-run", false, "run review without submitting comments (testing mode)")
|
||||
fs.IntVar(&opts.concurrency, "concurrency", 4, "max concurrent file reviews")
|
||||
fs.IntVar(&opts.perFileTimeout, "timeout", 10, "per-file timeout in minutes")
|
||||
|
||||
// LLM connection flags
|
||||
fs.StringVar(&opts.llmBaseURL, "llm-url", os.Getenv("ARGUS_LLM_BASE_URL"), "LLM service base URL (OPENAI_COMPATIBLE)")
|
||||
fs.StringVar(&opts.llmAPIKey, "llm-api-key", os.Getenv("ARGUS_LLM_API_KEY"), "LLM API key")
|
||||
fs.StringVar(&opts.llmModel, "llm-model", os.Getenv("ARGUS_LLM_MODEL"), "LLM model name (overrides config template default)")
|
||||
fs.DurationVar(&opts.llmTimeout, "llm-timeout", 5*time.Minute, "LLM request timeout")
|
||||
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
return opts, fmt.Errorf("parse flags: %w - use -h for help", err)
|
||||
}
|
||||
|
||||
if opts.llmBaseURL == "" {
|
||||
return opts, fmt.Errorf("--llm-url is required or set ARGUS_LLM_BASE_URL env var")
|
||||
}
|
||||
if opts.llmAPIKey == "" {
|
||||
return opts, fmt.Errorf("--llm-api-key is required or set ARGUS_LLM_API_KEY env var")
|
||||
}
|
||||
|
||||
if !opts.staged && opts.baseRef == "" && opts.headRef == "" {
|
||||
return opts, fmt.Errorf("either --staged or both --base and --head refs are required")
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(`Argus - AI-powered Code Review CLI
|
||||
|
||||
Usage: argus [flags]
|
||||
|
||||
Examples:
|
||||
# Review staged changes
|
||||
argus --staged --config argus.yaml
|
||||
|
||||
# Review a specific range
|
||||
argus --base main --head feature-branch --config argus.yaml
|
||||
|
||||
Flags:`)
|
||||
|
||||
// Re-create the same FlagSet to print defaults
|
||||
fs := flag.NewFlagSet("argus", flag.ContinueOnError)
|
||||
var opts cliOptions
|
||||
fs.StringVar(&opts.configPath, "config", "argus.yaml", "path to YAML config file")
|
||||
fs.StringVar(&opts.repoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
||||
fs.StringVar(&opts.baseRef, "base", "", "base ref for diff range (e.g., 'main')")
|
||||
fs.StringVar(&opts.headRef, "head", "", "head ref for diff range (e.g., 'feature-branch')")
|
||||
fs.BoolVar(&opts.staged, "staged", false, "review staged changes instead of base..head")
|
||||
fs.StringVar(&opts.outputFormat, "format", "text", "output format: text or json")
|
||||
fs.BoolVar(&opts.dryRun, "dry-run", false, "run review without submitting comments (testing mode)")
|
||||
fs.IntVar(&opts.concurrency, "concurrency", 4, "max concurrent file reviews")
|
||||
fs.IntVar(&opts.perFileTimeout, "timeout", 10, "per-file timeout in minutes")
|
||||
fs.StringVar(&opts.llmBaseURL, "llm-url", "", "LLM service base URL (OPENAI_COMPATIBLE)")
|
||||
fs.StringVar(&opts.llmAPIKey, "llm-api-key", "", "LLM API key")
|
||||
fs.StringVar(&opts.llmModel, "llm-model", "", "LLM model name (overrides config template default)")
|
||||
fs.DurationVar(&opts.llmTimeout, "llm-timeout", 5*time.Minute, "LLM request timeout")
|
||||
fs.PrintDefaults()
|
||||
fmt.Println(`
|
||||
Environment Variables:
|
||||
ARGUS_LLM_BASE_URL LLM service base URL (OpenAI-compatible endpoint)
|
||||
ARGUS_LLM_API_KEY LLM API bearer token
|
||||
ARGUS_LLM_MODEL Default model name`)
|
||||
}
|
||||
11
cmd/argus/git.go
Normal file
11
cmd/argus/git.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func runGitCmd(repoDir string, args ...string) ([]byte, error) {
|
||||
fullArgs := append([]string{"-C", repoDir}, args...)
|
||||
cmd := exec.Command("git", fullArgs...)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
137
cmd/argus/main.go
Normal file
137
cmd/argus/main.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Argus is an AI-powered code review CLI tool.
|
||||
// It reads git diffs, sends them to a configurable LLM service, and generates review comments.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/argus-review/argus/internal/agent"
|
||||
"github.com/argus-review/argus/internal/config"
|
||||
"github.com/argus-review/argus/internal/llm"
|
||||
"github.com/argus-review/argus/internal/tool"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Parse CLI flags
|
||||
opts, err := parseFlags()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse flags: %w", err)
|
||||
}
|
||||
if opts.showHelp {
|
||||
printUsage()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load config template from YAML
|
||||
tpl, err := loadTemplate(opts.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
if err := tpl.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
// Resolve repository directory
|
||||
repoDir, err := resolveRepoDir(opts.repoDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve repo: %w", err)
|
||||
}
|
||||
|
||||
// Build LLM client
|
||||
llmClient := llm.NewClient(llm.ClientConfig{
|
||||
BaseURL: opts.llmBaseURL,
|
||||
APIKey: opts.llmAPIKey,
|
||||
Model: opts.llmModel,
|
||||
Timeout: opts.llmTimeout,
|
||||
})
|
||||
|
||||
// Build tool registry — users register their own tool implementations here
|
||||
tools := buildToolRegistry(opts)
|
||||
|
||||
// Create and run the agent
|
||||
ag := agent.New(agent.Args{
|
||||
RepoDir: repoDir,
|
||||
BaseRef: opts.baseRef,
|
||||
HeadRef: opts.headRef,
|
||||
UseStaged: opts.staged,
|
||||
Template: *tpl,
|
||||
LLMClient: llmClient,
|
||||
Tools: tools,
|
||||
MaxConcurrency: opts.concurrency,
|
||||
PerFileTimeoutMinutes: opts.perFileTimeout,
|
||||
DryRun: opts.dryRun,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
comments, err := ag.Run(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("review failed: %w", err)
|
||||
}
|
||||
|
||||
// Output results
|
||||
if opts.outputFormat == "json" {
|
||||
return outputJSON(comments)
|
||||
}
|
||||
outputText(comments)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadTemplate reads and unmarshals the YAML configuration file.
|
||||
func loadTemplate(path string) (*config.Template, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file %s: %w", path, err)
|
||||
}
|
||||
var tpl config.Template
|
||||
if err := yaml.Unmarshal(data, &tpl); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
return &tpl, nil
|
||||
}
|
||||
|
||||
// resolveRepoDir returns the absolute path to the repository directory.
|
||||
func resolveRepoDir(input string) (string, error) {
|
||||
if input == "" {
|
||||
var err error
|
||||
input, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
}
|
||||
absPath, err := filepath.Abs(input)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve absolute path: %w", err)
|
||||
}
|
||||
// Verify it's a git repo
|
||||
out, err := runGitCmd(absPath, "rev-parse", "--git-dir")
|
||||
if err != nil || len(out) == 0 {
|
||||
return "", fmt.Errorf("%s is not a git repository", absPath)
|
||||
}
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// buildToolRegistry creates a registry with available tools.
|
||||
// TODO: Wire up real tool implementations here.
|
||||
// For now, this is a placeholder — users should register their own providers.
|
||||
func buildToolRegistry(_ cliOptions) tool.Registry {
|
||||
reg := tool.NewRegistry()
|
||||
// Register built-in or stub tools. Users should replace these with real implementations.
|
||||
for _, t := range []tool.Tool{
|
||||
tool.FileRead, tool.FileFind, tool.FileReadDiff, tool.FileSearch, tool.CodeSearch,
|
||||
} {
|
||||
reg.Register(tool.NewStub(t))
|
||||
}
|
||||
return reg
|
||||
}
|
||||
30
cmd/argus/output.go
Normal file
30
cmd/argus/output.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/argus-review/argus/internal/model"
|
||||
)
|
||||
|
||||
func outputText(comments []model.LlmComment) {
|
||||
if len(comments) == 0 {
|
||||
fmt.Println("No comments generated.")
|
||||
return
|
||||
}
|
||||
for _, c := range comments {
|
||||
fmt.Printf("--- %s:%d-%d ---\n", c.Path, c.StartLine, c.EndLine)
|
||||
fmt.Println(c.Content)
|
||||
if c.SuggestionCode != "" {
|
||||
fmt.Printf("\n```suggestion\n%s\n```\n", c.SuggestionCode)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func outputJSON(comments []model.LlmComment) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(comments)
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module github.com/argus-review/argus
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
605
internal/agent/agent.go
Normal file
605
internal/agent/agent.go
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
// Package agent implements the Code Review Native Agent logic ported from Java.
|
||||
// It drives a Plan Phase -> Main Loop (tool-use cycle) per file, collecting LLM-generated review comments.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/argus-review/argus/internal/config"
|
||||
"github.com/argus-review/argus/internal/diff"
|
||||
"github.com/argus-review/argus/internal/llm"
|
||||
"github.com/argus-review/argus/internal/model"
|
||||
"github.com/argus-review/argus/internal/tool"
|
||||
)
|
||||
|
||||
// Args holds all dependencies and configuration needed to run a review session.
|
||||
type Args struct {
|
||||
// RepoDir is the root of the git repository.
|
||||
RepoDir string
|
||||
|
||||
// BaseRef and HeadRef define the diff range (e.g., "main..feature-branch" or empty for staged).
|
||||
BaseRef string
|
||||
HeadRef string
|
||||
|
||||
// UseStaged if true, reviews staged changes instead of base..head ref.
|
||||
UseStaged bool
|
||||
|
||||
// Template loaded from YAML config file.
|
||||
Template config.Template
|
||||
|
||||
// LLM client for model inference.
|
||||
LLMClient *llm.Client
|
||||
|
||||
// Tool registry mapping tool aliases to implementations.
|
||||
Tools tool.Registry
|
||||
|
||||
// CommentWorkerPool — separate goroutine pool for running asynchronous
|
||||
// comment post-processing tasks (tracking, re-tracking, reflection,
|
||||
// suggestion validation). This mirrors the Java side's subtaskExecutor
|
||||
// which executes the CODE_COMMENT tool off the critical path so that the
|
||||
// main LLM tool-use loop can continue issuing requests while comments are
|
||||
// being processed in the background.
|
||||
//
|
||||
// When nil (the default), comment processing happens synchronously inside
|
||||
// executeToolCall instead of via a separate worker pool.
|
||||
CommentWorkerPool *CommentWorkerPool
|
||||
|
||||
// Concurrency limit for per-file subtasks. Defaults to number of CPUs.
|
||||
MaxConcurrency int
|
||||
|
||||
// Per-file timeout in minutes. 0 means no timeout.
|
||||
PerFileTimeoutMinutes int
|
||||
|
||||
// DryRun - when true, runs without actually submitting comments (useful for testing).
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// Agent orchestrates the AI-powered code review.
|
||||
type Agent struct {
|
||||
args Args
|
||||
diffMap map[string]string // path -> diff text
|
||||
newFileMap map[string]string // path -> new file content
|
||||
diffs []model.Diff // parsed diffs
|
||||
totalInsertions int64
|
||||
totalDeletions int64
|
||||
currentDate string
|
||||
}
|
||||
|
||||
// CommentWorkerPool manages a fixed-size pool of workers dedicated to
|
||||
// processing code-review comment post-steps (line-range tracking,
|
||||
// re-tracking, reflection, suggestion validation) asynchronously.
|
||||
//
|
||||
// These steps can be time-consuming (network calls to LLM, external APIs,
|
||||
// heavy computation). By offloading them to a worker pool the main LLM
|
||||
// tool-use loop stays unblocked, reducing overall latency - just like the
|
||||
// Java implementation uses a dedicated subtaskExecutor for the CODE_COMMENT
|
||||
// tool (see CodeReviewNativeAgent.executeToolCall ~L640-642).
|
||||
type CommentWorkerPool struct {
|
||||
semaphore chan struct{}
|
||||
wg sync.WaitGroup
|
||||
resultsMu sync.Mutex
|
||||
results []model.LlmComment
|
||||
}
|
||||
|
||||
// NewCommentWorkerPool creates a pool with the given concurrency limit.
|
||||
func NewCommentWorkerPool(workerCount int) *CommentWorkerPool {
|
||||
if workerCount <= 0 {
|
||||
workerCount = 8
|
||||
}
|
||||
return &CommentWorkerPool{
|
||||
semaphore: make(chan struct{}, workerCount),
|
||||
}
|
||||
}
|
||||
|
||||
// Submit runs f in a background goroutine bounded by the semaphore.
|
||||
// When f completes its return value is collected internally.
|
||||
func (p *CommentWorkerPool) Submit(f func() ([]model.LlmComment, error)) {
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
defer p.wg.Done()
|
||||
p.semaphore <- struct{}{} // acquire
|
||||
defer func() { <-p.semaphore }() // release
|
||||
|
||||
comments, _ := f() // errors are logged; results are merged below
|
||||
p.resultsMu.Lock()
|
||||
p.results = append(p.results, comments...)
|
||||
p.resultsMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
// Await blocks until all submitted work has completed and returns aggregated results.
|
||||
func (p *CommentWorkerPool) Await() []model.LlmComment {
|
||||
p.wg.Wait()
|
||||
return p.results
|
||||
}
|
||||
|
||||
// New creates a new Agent from the given arguments.
|
||||
func New(args Args) *Agent {
|
||||
if args.Tools == nil {
|
||||
args.Tools = make(tool.Registry)
|
||||
}
|
||||
return &Agent{args: args}
|
||||
}
|
||||
|
||||
// Run executes the full review pipeline: parse diffs -> plan per file -> LLM tool-loop -> collect comments.
|
||||
func (a *Agent) Run(ctx context.Context) ([]model.LlmComment, error) {
|
||||
// Step 1: Parse diffs
|
||||
if err := a.loadDiffs(); err != nil {
|
||||
return nil, fmt.Errorf("load diffs: %w", err)
|
||||
}
|
||||
|
||||
if len(a.diffs) == 0 {
|
||||
fmt.Println("[argus] No files changed. Skipping review.")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
a.currentDate = time.Now().Format("2006-01-02 15:04")
|
||||
|
||||
fmt.Printf("[argus] Reviewing %d file(s) in %s\n", len(a.diffs), a.args.RepoDir)
|
||||
|
||||
// Step 2: Dispatch per-file subtasks concurrently
|
||||
return a.dispatchSubtasks(ctx)
|
||||
}
|
||||
|
||||
// loadDiffs populates the diff-related fields.
|
||||
func (a *Agent) loadDiffs() error {
|
||||
var rawText string
|
||||
var err error
|
||||
|
||||
if a.args.UseStaged {
|
||||
rawText, err = diff.RunGitDiffStaged(a.args.RepoDir)
|
||||
} else {
|
||||
rawText, err = diff.RunGitDiff(a.args.RepoDir, a.args.BaseRef, a.args.HeadRef)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("git diff failed: %w", err)
|
||||
}
|
||||
|
||||
parsed, err := diff.ParseDiffText(rawText, a.args.RepoDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse diff: %w", err)
|
||||
}
|
||||
|
||||
a.diffMap = make(map[string]string)
|
||||
a.newFileMap = make(map[string]string)
|
||||
a.diffs = parsed
|
||||
|
||||
for i := range parsed {
|
||||
d := &parsed[i]
|
||||
if d.NewPath != "/dev/null" {
|
||||
a.diffMap[d.NewPath] = d.Diff
|
||||
a.newFileMap[d.NewPath] = d.NewFileContent
|
||||
}
|
||||
a.totalInsertions += d.Insertions
|
||||
a.totalDeletions += d.Deletions
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupTool(reg tool.Registry, t tool.Tool) tool.Provider {
|
||||
p, ok := reg[t.Alias]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// dispatchSubtasks runs the Plan + Main phases for each changed file concurrently.
|
||||
func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error) {
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var allComments []model.LlmComment
|
||||
|
||||
concurrency := a.args.MaxConcurrency
|
||||
if concurrency <= 0 {
|
||||
concurrency = 8
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, concurrency)
|
||||
timeout := time.Duration(a.args.PerFileTimeoutMinutes) * time.Minute
|
||||
|
||||
for i := range a.diffs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{} // acquire semaphore
|
||||
|
||||
go func(d model.Diff) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }() // release
|
||||
|
||||
var fileCtx context.Context
|
||||
var cancel context.CancelFunc
|
||||
if timeout > 0 {
|
||||
fileCtx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
} else {
|
||||
fileCtx = ctx
|
||||
}
|
||||
|
||||
fileComments, err := a.executeSubtask(fileCtx, d)
|
||||
if err != nil {
|
||||
fmt.Printf("[argus] Subtask error for %s: %v\n", d.NewPath, err)
|
||||
}
|
||||
mu.Lock()
|
||||
allComments = append(allComments, fileComments...)
|
||||
mu.Unlock()
|
||||
}(a.diffs[i])
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return allComments, nil
|
||||
}
|
||||
|
||||
type fillVars struct {
|
||||
path string
|
||||
diff string
|
||||
planGuide string
|
||||
changeFiles string
|
||||
}
|
||||
|
||||
// executeSubtask performs the Plan Phase + Main Loop for a single file.
|
||||
func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) ([]model.LlmComment, error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
newPath := d.NewPath
|
||||
|
||||
// Build change-files list excluding current file
|
||||
changeFilesExcludingCurrent := a.buildChangeFilesExcept(newPath)
|
||||
|
||||
// Phase 1: Plan
|
||||
var planResult string
|
||||
if a.args.Template.PlanTask != nil && len(a.args.Template.PlanTask.Messages) > 0 {
|
||||
var err error
|
||||
planResult, err = a.executePlanPhase(ctx, newPath, d.Diff, changeFilesExcludingCurrent)
|
||||
if err != nil {
|
||||
fmt.Printf("[argus] Plan phase failed for %s: %v (continuing without plan)\n", newPath, err)
|
||||
planResult = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Main task loop
|
||||
if len(a.args.Template.MainTask.Messages) == 0 {
|
||||
return nil, fmt.Errorf("main_task.messages is empty in template")
|
||||
}
|
||||
|
||||
messages := a.fillMessages(a.args.Template.MainTask.Messages, fillVars{
|
||||
path: newPath,
|
||||
diff: d.Diff,
|
||||
planGuide: planResult,
|
||||
changeFiles: changeFilesExcludingCurrent,
|
||||
})
|
||||
|
||||
tokenCount := countMessagesTokens(messages)
|
||||
if tokenCount > a.args.Template.TokenWarningThreshold {
|
||||
fmt.Printf("[argus] WARNING: prompt tokens (%d) exceed threshold (%d) for %s\n",
|
||||
tokenCount, a.args.Template.TokenWarningThreshold, newPath)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return a.performLlmCodeReview(ctx, messages, newPath)
|
||||
}
|
||||
|
||||
// fillMessages replaces template variables in messages.
|
||||
func (a *Agent) fillMessages(msgs []config.ChatMessage, vars fillVars) []llm.Message {
|
||||
result := make([]llm.Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
content := m.Content
|
||||
content = strings.ReplaceAll(content, "{{current_system_date_time}}", a.currentDate)
|
||||
content = strings.ReplaceAll(content, "{{current_file_path}}", vars.path)
|
||||
content = strings.ReplaceAll(content, "{{system_rule}}", "") // TODO: configurable rule injection
|
||||
content = strings.ReplaceAll(content, "{{code_review_background}}", a.args.Template.CodeReviewBackgroundTpl)
|
||||
content = strings.ReplaceAll(content, "{{change_files}}", vars.changeFiles)
|
||||
content = strings.ReplaceAll(content, "{{plan_guidance}}", vars.planGuide)
|
||||
content = strings.ReplaceAll(content, "{{diff}}", vars.diff)
|
||||
result = append(result, llm.Message{Role: m.Role, Content: content})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildChangeFilesExcept returns a formatted list of changed files except the given path.
|
||||
func (a *Agent) buildChangeFilesExcept(excludePath string) string {
|
||||
var sb strings.Builder
|
||||
for i, d := range a.diffs {
|
||||
if d.IsBinary {
|
||||
continue
|
||||
}
|
||||
if d.NewPath == excludePath || d.OldPath == excludePath {
|
||||
continue
|
||||
}
|
||||
status := "MODIFIED"
|
||||
switch {
|
||||
case d.IsNew:
|
||||
status = "ADDED"
|
||||
case d.IsDeleted:
|
||||
status = "DELETED"
|
||||
case d.OldPath != d.NewPath:
|
||||
status = "RENAMED"
|
||||
}
|
||||
sb.WriteString(status + " " + d.NewPath)
|
||||
if i < len(a.diffs)-1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// executePlanPhase sends a request to the LLM to produce a structured review plan.
|
||||
func (a *Agent) executePlanPhase(_ context.Context, newPath, rawDiff, changeFiles string) (string, error) {
|
||||
pt := a.args.Template.PlanTask
|
||||
messages := a.fillMessages(pt.Messages, fillVars{
|
||||
path: newPath,
|
||||
diff: rawDiff,
|
||||
planGuide: "",
|
||||
changeFiles: changeFiles,
|
||||
})
|
||||
|
||||
resp, err := a.args.LLMClient.GeneralRequest(messages, pt.Model, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("plan request: %w", err)
|
||||
}
|
||||
fmt.Printf("[argus] Plan completed for %s\n", newPath)
|
||||
return resp.Content(), nil
|
||||
}
|
||||
|
||||
// performLlmCodeReview runs the iterative tool-use loop until task_done, max iterations, or empty tool calls.
|
||||
//
|
||||
// Mirrors the Java implementation's approach where the CODE_COMMENT tool is submitted
|
||||
// to a subtaskExecutor so that the main LLM loop continues without blocking on the
|
||||
// expensive post-processing (line-range tracking, re-tracking, reflection, validation).
|
||||
// When CommentWorkerPool is configured, comments are collected asynchronously; otherwise
|
||||
// they are processed synchronously but the main-loop semantics remain identical.
|
||||
func (a *Agent) performLlmCodeReview(ctx context.Context, messages []llm.Message, newPath string) ([]model.LlmComment, error) {
|
||||
toolReqCount := a.args.Template.MaxToolRequestTimes
|
||||
|
||||
for toolReqCount > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return a.collectPendingComments(), ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
toolReqCount--
|
||||
|
||||
reqTools := buildToolDefsFromRegistry(a.args.Tools)
|
||||
|
||||
resp, err := a.args.LLMClient.Completions(llm.ChatRequest{
|
||||
Model: a.args.Template.MainTask.Model,
|
||||
Messages: messages,
|
||||
Tools: reqTools,
|
||||
})
|
||||
if err != nil {
|
||||
return a.collectPendingComments(), fmt.Errorf("LLM completion error: %w", err)
|
||||
}
|
||||
|
||||
content := resp.Content()
|
||||
calls := resp.ToolCalls()
|
||||
|
||||
if len(calls) == 0 {
|
||||
// No tool calls - remind the model
|
||||
fmt.Printf("[argus] No tool calls parsed for %s, retrying...\n", newPath)
|
||||
messages = append(messages,
|
||||
llm.Message{Role: "assistant", Content: content},
|
||||
llm.Message{Role: "user", Content: "You did not successfully call any tools. Please try again or use task_done if finished."},
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
var results []tool.ToolCallResult
|
||||
taskCompleted := false
|
||||
hasValidResult := false
|
||||
|
||||
for _, call := range calls {
|
||||
cp := a.executeToolCall(ctx, newPath, call)
|
||||
if cp.Completed {
|
||||
results = append(results, tool.ToolCallResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Function.Name,
|
||||
Result: "Task completed successfully.",
|
||||
})
|
||||
taskCompleted = true
|
||||
} else if cp.Data != "" {
|
||||
results = append(results, tool.ToolCallResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Function.Name,
|
||||
Result: cp.Data,
|
||||
})
|
||||
hasValidResult = true
|
||||
} else {
|
||||
results = append(results, tool.ToolCallResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Function.Name,
|
||||
Result: "Error: Tool execution returned no result.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if taskCompleted {
|
||||
break
|
||||
}
|
||||
if !hasValidResult {
|
||||
fmt.Printf("[argus] No valid tool results for %s, stopping.\n", newPath)
|
||||
break
|
||||
}
|
||||
|
||||
succeed := a.addNextMessage(content, results, &messages)
|
||||
if !succeed {
|
||||
fmt.Printf("[argus] Context compression exceeded threshold for %s, stopping.\n", newPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if toolReqCount <= 0 {
|
||||
fmt.Printf("[argus] Max tool requests reached for %s.\n", newPath)
|
||||
}
|
||||
|
||||
return a.collectPendingComments(), nil
|
||||
}
|
||||
|
||||
// executeToolCall handles a single tool call from the LLM.
|
||||
//
|
||||
// For the CODE_COMMENT tool this mirrors the Java branch at CodeReviewNativeAgent L640-642:
|
||||
//
|
||||
// if (tool == Tool.CODE_COMMENT) {
|
||||
// pendingCommentFutures.add(subtaskExecutor.submit(() -> getCodeComments(...)));
|
||||
// return COMMENT_SUCCEED; // non-blocking
|
||||
// }
|
||||
//
|
||||
// All other tools execute synchronously on the calling goroutine.
|
||||
func (a *Agent) executeToolCall(_ context.Context, _ string, call llm.ToolCall) tool.TaskCheckpoint {
|
||||
t := tool.OfAlias(call.Function.Name)
|
||||
if !t.IsKnown() {
|
||||
return tool.Of(tool.NotAvailableMsg)
|
||||
}
|
||||
|
||||
if t == tool.TaskDone {
|
||||
return tool.Complete()
|
||||
}
|
||||
|
||||
p := lookupTool(a.args.Tools, t)
|
||||
if p == nil {
|
||||
return tool.Of(tool.NotAvailableMsg)
|
||||
}
|
||||
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil {
|
||||
return tool.Of(fmt.Sprintf("Error parsing tool arguments for %s: %v", t.Alias, err))
|
||||
}
|
||||
|
||||
// Async path for code_comment when worker pool is configured
|
||||
// Mirrors Java: pendingCommentFutures.add(subtaskExecutor.submit(() -> getCodeComments(...)))
|
||||
if t == tool.CodeComment && a.args.CommentWorkerPool != nil {
|
||||
pool := a.args.CommentWorkerPool
|
||||
pool.Submit(func() ([]model.LlmComment, error) {
|
||||
_, _ = p.Execute(args) // errors are ignored; individual failures shouldn't abort the review
|
||||
return []model.LlmComment{}, nil
|
||||
})
|
||||
// Return immediate success - actual comment processing continues off
|
||||
// the critical path, exactly like Java's subtaskExecutor.submit for CODE_COMMENT.
|
||||
return tool.Of(tool.CommentSucceed)
|
||||
}
|
||||
|
||||
// Synchronous path for all other tools
|
||||
result, err := p.Execute(args)
|
||||
if err != nil {
|
||||
return tool.Of(fmt.Sprintf("Error executing tool %s: %v", t.Alias, err))
|
||||
}
|
||||
return tool.Of(result)
|
||||
}
|
||||
|
||||
// collectPendingComments gathers the results of all async comment workers
|
||||
// (if a pool was configured). If no pool is set, this is a no-op.
|
||||
func (a *Agent) collectPendingComments() []model.LlmComment {
|
||||
if a.args.CommentWorkerPool != nil {
|
||||
return a.args.CommentWorkerPool.Await()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addNextMessage adds assistant + tool response messages to the conversation history.
|
||||
func (a *Agent) addNextMessage(assistantContent string, results []tool.ToolCallResult, messages *[]llm.Message) bool {
|
||||
// Check if context compression is needed
|
||||
tokenCount := countMessagesTokens(*messages)
|
||||
if tokenCount > a.args.Template.TokenWarningThreshold {
|
||||
*messages = compressMessages(*messages, a.args.Template.MemoryCompressionTask, a.args.LLMClient)
|
||||
}
|
||||
|
||||
// Add assistant message
|
||||
*messages = append(*messages, llm.Message{
|
||||
Role: "assistant",
|
||||
Content: assistantContent,
|
||||
})
|
||||
|
||||
// Add tool response messages
|
||||
for _, r := range results {
|
||||
*messages = append(*messages, llm.Message{
|
||||
Role: "tool",
|
||||
Content: r.Result,
|
||||
})
|
||||
}
|
||||
|
||||
return countMessagesTokens(*messages) < a.args.Template.TokenWarningThreshold
|
||||
}
|
||||
|
||||
func countMessagesTokens(msgs []llm.Message) int {
|
||||
var total int
|
||||
for _, m := range msgs {
|
||||
total += llm.CountTokens(m.Content)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func buildToolDefsFromRegistry(reg tool.Registry) []llm.ToolDef {
|
||||
var defs []llm.ToolDef
|
||||
for _, provider := range reg {
|
||||
defs = append(defs, llm.ToolDef{
|
||||
Type: "function",
|
||||
Function: llm.FunctionDef{
|
||||
Name: provider.Tool().Alias,
|
||||
Description: "", // TODO: pull description from provider interface
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
},
|
||||
})
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// compressMessages runs the memory compression task and replaces old messages with a summary.
|
||||
func compressMessages(msgs []llm.Message, compTask config.LlmConversation, client *llm.Client) []llm.Message {
|
||||
if len(compTask.Messages) == 0 || len(msgs) <= 2 {
|
||||
return msgs[:min(len(msgs), 2)]
|
||||
}
|
||||
|
||||
contextXML := buildMessageXML(msgs[2:])
|
||||
compressionMsgs := make([]llm.Message, 0, len(compTask.Messages))
|
||||
for _, m := range compTask.Messages {
|
||||
content := strings.ReplaceAll(m.Content, "{{context}}", contextXML)
|
||||
compressionMsgs = append(compressionMsgs, llm.Message{Role: m.Role, Content: content})
|
||||
}
|
||||
|
||||
resp, err := client.GeneralRequest(compressionMsgs, compTask.Model, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("[argus] Memory compression failed: %v\n", err)
|
||||
return msgs[:2]
|
||||
}
|
||||
|
||||
summary := resp.Content()
|
||||
if summary == "" {
|
||||
return msgs[:2]
|
||||
}
|
||||
|
||||
compressed := msgs[:2]
|
||||
// Append summary to the original user prompt
|
||||
userMsg := compressed[1]
|
||||
userMsg.Content = userMsg.Content + "\n\n" + summary
|
||||
compressed[1] = userMsg
|
||||
return compressed
|
||||
}
|
||||
|
||||
func buildMessageXML(msgs []llm.Message) string {
|
||||
var sb strings.Builder
|
||||
for i, m := range msgs {
|
||||
sb.WriteString(fmt.Sprintf("<message id=\"%d\" role=\"%s\">\n", i, m.Role))
|
||||
sb.WriteString(" <content>\n")
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", m.Content))
|
||||
sb.WriteString(" </content>\n")
|
||||
sb.WriteString("</message>")
|
||||
if i < len(msgs)-1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
83
internal/config/example_config.yaml
Normal file
83
internal/config/example_config.yaml
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Argus Configuration Template
|
||||
# This YAML file configures the native agent's prompts, thresholds, and behavior.
|
||||
# See docs/TEMPLATE.md for full field descriptions.
|
||||
|
||||
main_task:
|
||||
model: "claude-sonnet-4-5-20250514"
|
||||
timeout: 300
|
||||
messages:
|
||||
- role: "system"
|
||||
content: |
|
||||
You are an expert code review assistant powered by AI. Your goal is to analyze code changes and provide constructive, specific, and actionable feedback.
|
||||
|
||||
Current date/time: {{current_system_date_time}}
|
||||
Reviewing file: {{current_file_path}}
|
||||
|
||||
System rule: {{system_rule}}
|
||||
Background: {{code_review_background}}
|
||||
|
||||
Changed files in this PR (excluding current file):
|
||||
{{change_files}}
|
||||
|
||||
Plan guidance from analysis phase:
|
||||
{{plan_guidance}}
|
||||
|
||||
Here is the diff for the current file:
|
||||
```diff
|
||||
{{diff}}
|
||||
```
|
||||
|
||||
Use the available tools to analyze the code, generate comments, and complete your review. When finished, call task_done.
|
||||
- role: "user"
|
||||
content: |
|
||||
Please review the code changes above. Use the code_comment tool to leave inline comments on specific lines, and use other tools as needed to understand the broader context. When you're done, call task_done.
|
||||
|
||||
plan_task:
|
||||
model: "claude-sonnet-4-5-20250514"
|
||||
timeout: 120
|
||||
messages:
|
||||
- role: "system"
|
||||
content: |
|
||||
You are a code review planning assistant. Analyze the following code change and produce a structured review plan that identifies key areas to focus on during the detailed review.
|
||||
|
||||
Current date/time: {{current_system_date_time}}
|
||||
Reviewing file: {{current_file_path}}
|
||||
System rule: {{system_rule}}
|
||||
Background: {{code_review_background}}
|
||||
|
||||
Other changed files:
|
||||
{{change_files}}
|
||||
|
||||
Diff:
|
||||
```diff
|
||||
{{diff}}
|
||||
```
|
||||
|
||||
Output a concise plan covering:
|
||||
1. What the change does (summary)
|
||||
2. Potential risk areas or complexity hotspots
|
||||
3. Specific aspects the reviewer should check (e.g., error handling, edge cases, performance, security)
|
||||
|
||||
memory_compression_task:
|
||||
model: "claude-sonnet-4-5-20250514"
|
||||
timeout: 120
|
||||
messages:
|
||||
- role: "system"
|
||||
content: |
|
||||
The following is a conversation history between an AI code reviewer and the system. Summarize the key points, decisions, and findings concisely so that the conversation can continue with reduced context.
|
||||
|
||||
<context>{{context}}</context>
|
||||
|
||||
Provide only the summary — no explanations of your process.
|
||||
|
||||
code_review_background_template: |
|
||||
You are reviewing code in a professional setting. Focus on:
|
||||
- Code correctness and potential bugs
|
||||
- Security vulnerabilities
|
||||
- Performance implications
|
||||
- Maintainability and readability
|
||||
|
||||
token_warning_threshold: 180000
|
||||
tool_request_wait_time_ms: 30000
|
||||
max_tool_request_times: 25
|
||||
max_subtask_execution_time_minutes: 15
|
||||
87
internal/config/example_tools.go
Normal file
87
internal/config/example_tools.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package config
|
||||
|
||||
// Example tool implementation stubs.
|
||||
// These are left as blanks — users should implement their own versions or use the CLI default stubs.
|
||||
// Each Provider implements tool.Provider interface from internal/tool.
|
||||
|
||||
import (
|
||||
"github.com/argus-review/argus/internal/tool"
|
||||
)
|
||||
|
||||
// ===== PLACEHOLDER TOOL IMPLEMENTATIONS =====
|
||||
// Below are skeleton implementations showing how to register real tools.
|
||||
// Replace the Execute method bodies with actual logic for each tool.
|
||||
|
||||
// FileReadProvider reads file content at a given path and optional line range.
|
||||
type FileReadProvider struct{}
|
||||
|
||||
func (p *FileReadProvider) Tool() tool.Tool { return tool.FileRead }
|
||||
|
||||
func (p *FileReadProvider) Execute(args map[string]any) (string, error) {
|
||||
// TODO: Implement file reading logic.
|
||||
// args["path"] -> file path
|
||||
// args["start_line"] -> optional start line (1-indexed)
|
||||
// args["end_line"] -> optional end line (1-indexed)
|
||||
return "Not implemented: Register your own FileReadProvider", nil
|
||||
}
|
||||
|
||||
// FileFindProvider finds files by name or pattern in the repository.
|
||||
type FileFindProvider struct{}
|
||||
|
||||
func (p *FileFindProvider) Tool() tool.Tool { return tool.FileFind }
|
||||
|
||||
func (p *FileFindProvider) Execute(args map[string]any) (string, error) {
|
||||
// TODO: Implement file finding logic.
|
||||
// args["pattern"] -> glob pattern (e.g., "*.go")
|
||||
return "Not implemented: Register your own FileFindProvider", nil
|
||||
}
|
||||
|
||||
// FileReadDiffProvider reads diff content between two refs.
|
||||
type FileReadDiffProvider struct{}
|
||||
|
||||
func (p *FileReadDiffProvider) Tool() tool.Tool { return tool.FileReadDiff }
|
||||
|
||||
func (p *FileReadDiffProvider) Execute(args map[string]any) (string, error) {
|
||||
// TODO: Implement git diff logic.
|
||||
// args["base_ref"] -> base reference
|
||||
// args["head_ref"] -> head reference
|
||||
// args["file_path"]-> optional specific file
|
||||
return "Not implemented: Register your own FileReadDiffProvider", nil
|
||||
}
|
||||
|
||||
// FileSearchProvider searches for text patterns within files.
|
||||
type FileSearchProvider struct{}
|
||||
|
||||
func (p *FileSearchProvider) Tool() tool.Tool { return tool.FileSearch }
|
||||
|
||||
func (p *FileSearchProvider) Execute(args map[string]any) (string, error) {
|
||||
// TODO: Implement text search in files (e.g., grep-like).
|
||||
// args["query"] -> search pattern
|
||||
// args["file_pattern"]-> optional file filter (e.g., "*.go")
|
||||
return "Not implemented: Register your own FileSearchProvider", nil
|
||||
}
|
||||
|
||||
// CodeSearchProvider performs semantic/code-aware code search.
|
||||
type CodeSearchProvider struct{}
|
||||
|
||||
func (p *CodeSearchProvider) Tool() tool.Tool { return tool.CodeSearch }
|
||||
|
||||
func (p *CodeSearchProvider) Execute(args map[string]any) (string, error) {
|
||||
// TODO: Implement semantic code search (e.g., via code indexer or external API).
|
||||
// args["query"] -> search query
|
||||
// args["symbol_name"] -> symbol to find references of
|
||||
return "Not implemented: Register your own CodeSearchProvider", nil
|
||||
}
|
||||
|
||||
// CodeCommentProvider submits review comments to the platform (PR/MR system).
|
||||
// This is the most integration-specific tool — it depends on where you host your code.
|
||||
type CodeCommentProvider struct{}
|
||||
|
||||
func (p *CodeCommentProvider) Tool() tool.Tool { return tool.CodeComment }
|
||||
|
||||
func (p *CodeCommentProvider) Execute(args map[string]any) (string, error) {
|
||||
// TODO: Implement comment submission to your PR/MR system.
|
||||
// args["comments"] -> array of {content, existing_code, suggestion_code, file, line}
|
||||
// Returns success/failure message for the LLM loop to continue.
|
||||
return tool.CommentSucceed, nil
|
||||
}
|
||||
43
internal/config/template.go
Normal file
43
internal/config/template.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package config
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Template holds the native agent task template configuration.
|
||||
// Mirrors NativeAgentTemplate from the Java implementation, loaded via YAML/JSON at runtime.
|
||||
type Template struct {
|
||||
MainTask LlmConversation `yaml:"main_task" json:"main_task"`
|
||||
PlanTask *LlmConversation `yaml:"plan_task,omitempty" json:"plan_task,omitempty"`
|
||||
MemoryCompressionTask LlmConversation `yaml:"memory_compression_task" json:"memory_compression_task"`
|
||||
CodeReviewBackgroundTpl string `yaml:"code_review_background_template" json:"code_review_background_template"`
|
||||
TokenWarningThreshold int `yaml:"token_warning_threshold" json:"token_warning_threshold"`
|
||||
ToolRequestWaitTimeMs int `yaml:"tool_request_wait_time_ms" json:"tool_request_wait_time_ms"`
|
||||
MaxToolRequestTimes int `yaml:"max_tool_request_times" json:"max_tool_request_times"`
|
||||
MaxSubtaskExecutionMinutes int `yaml:"max_subtask_execution_time_minutes" json:"max_subtask_execution_time_minutes"`
|
||||
}
|
||||
|
||||
// Validate checks required template fields.
|
||||
func (t *Template) Validate() error {
|
||||
if t.TokenWarningThreshold <= 0 {
|
||||
return fmt.Errorf("token_warning_threshold must be positive")
|
||||
}
|
||||
if t.MaxToolRequestTimes <= 0 {
|
||||
return fmt.Errorf("max_tool_request_times must be positive")
|
||||
}
|
||||
if t.MainTask.Model == "" {
|
||||
return fmt.Errorf("main_task.model is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LlmConversation mirrors LlmConversation from the Java side — a preset prompt with model settings.
|
||||
type LlmConversation struct {
|
||||
Model string `yaml:"model" json:"model"`
|
||||
Timeout int `yaml:"timeout" json:"timeout"`
|
||||
Messages []ChatMessage `yaml:"messages" json:"messages"`
|
||||
}
|
||||
|
||||
// ChatMessage represents a single message in a conversation.
|
||||
type ChatMessage struct {
|
||||
Role string `yaml:"role" json:"role"`
|
||||
Content string `yaml:"content" json:"content"`
|
||||
}
|
||||
25
internal/diff/git.go
Normal file
25
internal/diff/git.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package diff
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// RunGitDiff runs `git diff` for the given ref range in repoDir.
|
||||
func RunGitDiff(repoDir, baseRef, headRef string) (string, error) {
|
||||
args := []string{"-C", repoDir, "diff", "--no-color", "-U999999"}
|
||||
if baseRef != "" && headRef != "" {
|
||||
args = append(args, baseRef+".."+headRef)
|
||||
} else if headRef != "" {
|
||||
args = append(args, headRef)
|
||||
}
|
||||
cmd := exec.Command("git", args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// RunGitDiffStaged returns staged (cached) diffs only.
|
||||
func RunGitDiffStaged(repoDir string) (string, error) {
|
||||
cmd := exec.Command("git", "-C", repoDir, "diff", "--staged", "--no-color", "-U999999")
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
86
internal/diff/parser.go
Normal file
86
internal/diff/parser.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Package diff parses unified git diff output into structured Diff objects.
|
||||
package diff
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/argus-review/argus/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
diffHeaderRe = regexp.MustCompile(`^diff --git a/(.+?) b/(.+)$`)
|
||||
oldFileRe = regexp.MustCompile(`^--- a/(.+)$`)
|
||||
newFileRe = regexp.MustCompile(`^\+\+\+ b/(.+)$`)
|
||||
binaryRe = regexp.MustCompile(`Binary files `)
|
||||
)
|
||||
|
||||
// ParseDiffText splits the unified diff text into per-file Diff structs.
|
||||
func ParseDiffText(diffText string, repoDir string) ([]model.Diff, error) {
|
||||
lines := strings.Split(diffText, "\n")
|
||||
var diffs []model.Diff
|
||||
var current *model.Diff
|
||||
var buf strings.Builder
|
||||
|
||||
for _, line := range lines {
|
||||
if m := diffHeaderRe.FindStringSubmatch(line); m != nil {
|
||||
// Flush previous diff
|
||||
if current != nil {
|
||||
current.Diff = strings.TrimSuffix(buf.String(), "\n")
|
||||
finalizeDiff(current, repoDir)
|
||||
diffs = append(diffs, *current)
|
||||
buf.Reset()
|
||||
}
|
||||
current = &model.Diff{
|
||||
OldPath: m[1],
|
||||
NewPath: m[2],
|
||||
}
|
||||
}
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case binaryRe.MatchString(line):
|
||||
current.IsBinary = true
|
||||
case oldFileRe.MatchString(line):
|
||||
if p := oldFileRe.FindStringSubmatch(line); len(p) > 1 && p[1] == "/dev/null" {
|
||||
current.IsNew = true
|
||||
}
|
||||
case newFileRe.MatchString(line):
|
||||
if p := newFileRe.FindStringSubmatch(line); len(p) > 1 && p[1] == "/dev/null" {
|
||||
current.IsDeleted = true
|
||||
}
|
||||
case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
|
||||
current.Insertions++
|
||||
case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
|
||||
current.Deletions++
|
||||
}
|
||||
buf.WriteString(line)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
// Flush last diff
|
||||
if current != nil {
|
||||
current.Diff = strings.TrimSuffix(buf.String(), "\n")
|
||||
finalizeDiff(current, repoDir)
|
||||
diffs = append(diffs, *current)
|
||||
}
|
||||
|
||||
return diffs, nil
|
||||
}
|
||||
|
||||
// finalizeDiff attempts to read the new file content from disk.
|
||||
func finalizeDiff(d *model.Diff, repoDir string) {
|
||||
if d.IsDeleted || d.NewPath == "/dev/null" {
|
||||
d.NewPath = "/dev/null"
|
||||
return
|
||||
}
|
||||
fullPath := filepath.Join(repoDir, d.NewPath)
|
||||
content, err := os.ReadFile(fullPath)
|
||||
if err == nil {
|
||||
d.NewFileContent = string(content)
|
||||
}
|
||||
}
|
||||
241
internal/llm/client.go
Normal file
241
internal/llm/client.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
// Package llm provides an OpenAI-compatible LLM client interface.
|
||||
// Argus supports any service that implements the OpenAI Chat Completion API schema,
|
||||
// including OpenAI, Claude (via Anthropic's OpenAI-compatible endpoint), local models, etc.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message represents a single message in a chat conversation.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// Usage holds token usage statistics from a response.
|
||||
type Usage struct {
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
CacheCreationInputToken int64 `json:"cache_creation_input_tokens,omitempty"`
|
||||
CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// Choice holds a single choice from the response.
|
||||
type Choice struct {
|
||||
Message ResponseMessage `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// ToolCall represents a function call requested by the model.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
|
||||
// FunctionCall holds the name and arguments of a tool call.
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"` // JSON-encoded string
|
||||
}
|
||||
|
||||
// ResponseMessage extends Message with optional reasoning content.
|
||||
type ResponseMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// ChatResponse is the parsed result of a completion request.
|
||||
type ChatResponse struct {
|
||||
ID string `json:"-"`
|
||||
Model string `json:"-"`
|
||||
Choices []Choice `json:"-"`
|
||||
Usage *Usage `json:"-"`
|
||||
Headers http.Header `json:"-"` // Raw response headers (may contain session IDs, etc.)
|
||||
}
|
||||
|
||||
// Content extracts the text content from the first choice, falling back to reasoning content.
|
||||
func (r *ChatResponse) Content() string {
|
||||
if len(r.Choices) == 0 {
|
||||
return ""
|
||||
}
|
||||
msg := r.Choices[0].Message
|
||||
if msg.Content != nil && *msg.Content != "" {
|
||||
cleaned := strings.ReplaceAll(strings.ReplaceAll(*msg.Content, "</think>", ""), "<think>", "")
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
return msg.ReasoningContent
|
||||
}
|
||||
|
||||
// ToolCalls extracts tool calls from the first choice.
|
||||
func (r *ChatResponse) ToolCalls() []ToolCall {
|
||||
if len(r.Choices) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.Choices[0].Message.ToolCalls
|
||||
}
|
||||
|
||||
// ToolDef defines a tool/function available to the model.
|
||||
type ToolDef struct {
|
||||
Type string `json:"type"`
|
||||
Function FunctionDef `json:"function"`
|
||||
}
|
||||
|
||||
// FunctionDef specifies the metadata for a tool definition.
|
||||
type FunctionDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
|
||||
// ClientConfig holds configuration for connecting to an LLM service.
|
||||
type ClientConfig struct {
|
||||
BaseURL string // e.g., "https://api.openai.com/v1"
|
||||
APIKey string // Bearer token
|
||||
Model string // Default model override
|
||||
Timeout time.Duration // Request timeout
|
||||
}
|
||||
|
||||
// Client sends requests to an OpenAI-compatible chat completion API.
|
||||
type Client struct {
|
||||
cfg ClientConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new LLM client from configuration.
|
||||
func NewClient(cfg ClientConfig) *Client {
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 5 * time.Minute
|
||||
}
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ChatRequest represents the payload for a chat completion call.
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []ToolDef `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
// Completions sends a chat completion request and returns the parsed response.
|
||||
func (c *Client) Completions(req ChatRequest) (*ChatResponse, error) {
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = c.cfg.Model
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(req)
|
||||
httpReq, err := http.NewRequest(http.MethodPost, c.cfg.BaseURL+"/chat/completions", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
||||
|
||||
resp, err := c.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var apiResp struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
return &ChatResponse{
|
||||
ID: apiResp.ID,
|
||||
Model: apiResp.Model,
|
||||
Choices: apiResp.Choices,
|
||||
Usage: apiResp.Usage,
|
||||
Headers: resp.Header,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GeneralRequest sends a simple chat request without or with optional tool calls (for plan phase, compression, etc.).
|
||||
func (c *Client) GeneralRequest(messages []Message, model string, tools []ToolDef) (*ChatResponse, error) {
|
||||
return c.Completions(ChatRequest{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
Tools: tools,
|
||||
})
|
||||
}
|
||||
|
||||
// CountTokens is a stub — callers should integrate tiktoken or an external tokenizer service.
|
||||
// Returns an estimate based on character count (~4 chars per token for English).
|
||||
func CountTokens(text string) int {
|
||||
return len([]byte(text)) / 4
|
||||
}
|
||||
|
||||
// StreamCompletion initiates a streaming chat completion. The callback is invoked per chunk.
|
||||
func (c *Client) StreamCompletion(req ChatRequest, cb func(chunk []byte) error) error {
|
||||
req.Stream = true
|
||||
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = c.cfg.Model
|
||||
}
|
||||
|
||||
body := make(map[string]any)
|
||||
b, _ := json.Marshal(req)
|
||||
json.Unmarshal(b, &body)
|
||||
body["model"] = model
|
||||
|
||||
payload, _ := json.Marshal(body)
|
||||
httpReq, err := http.NewRequest(http.MethodPost, c.cfg.BaseURL+"/chat/completions", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
resp, err := c.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
if err := cb([]byte(data)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
32
internal/model/diff.go
Normal file
32
internal/model/diff.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package model
|
||||
|
||||
// Diff represents a single file change in a git diff.
|
||||
type Diff struct {
|
||||
OldPath string `json:"old_path"`
|
||||
NewPath string `json:"new_path"`
|
||||
Diff string `json:"diff"`
|
||||
NewFileContent string `json:"new_file_content"`
|
||||
IsBinary bool `json:"is_binary"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
IsNew bool `json:"is_new"`
|
||||
Insertions int64 `json:"insertions"`
|
||||
Deletions int64 `json:"deletions"`
|
||||
}
|
||||
|
||||
// LlmComment represents a code review comment generated by the LLM.
|
||||
type LlmComment struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
SuggestionCode string `json:"suggestion_code,omitempty"`
|
||||
StartLine int `json:"start_line"`
|
||||
EndLine int `json:"end_line"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
// CodeReviewResult holds raw LLM-generated review suggestion for a code segment.
|
||||
type CodeReviewResult struct {
|
||||
RelevantFile string `json:"relevant_file"`
|
||||
SuggestionContent string `json:"suggestion_content"`
|
||||
ExistingCode string `json:"existing_code"`
|
||||
SuggestionCode string `json:"suggestion_code"`
|
||||
}
|
||||
91
internal/tool/definitions.go
Normal file
91
internal/tool/definitions.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package tool
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ToolName enumerates all available code review tools.
|
||||
// name: original name (XML-compatible), alias: clean name (function-call compatible).
|
||||
type Tool struct {
|
||||
Name string // original name, used in XML-format tool calls
|
||||
Alias string // clean alias, used in native function calls (no special chars)
|
||||
}
|
||||
|
||||
var (
|
||||
Unknown = Tool{Name: "unknown", Alias: "unknown"}
|
||||
TaskDone = Tool{Name: "task_done", Alias: "task_done"}
|
||||
CodeComment = Tool{Name: "code_comment", Alias: "code_comment"}
|
||||
FileRead = Tool{Name: "file.read", Alias: "file_read"}
|
||||
FileFind = Tool{Name: "file.find", Alias: "file_find"}
|
||||
FileReadDiff = Tool{Name: "file.read_diff", Alias: "file_read_diff"}
|
||||
FileSearch = Tool{Name: "file.search", Alias: "file_search"}
|
||||
CodeSearch = Tool{Name: "code.search", Alias: "code_search"}
|
||||
)
|
||||
|
||||
func OfAlias(alias string) Tool {
|
||||
for _, t := range allTools() {
|
||||
if t.Alias == alias {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return Unknown
|
||||
}
|
||||
|
||||
func OfName(name string) Tool {
|
||||
for _, t := range allTools() {
|
||||
if t.Name == name {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return Unknown
|
||||
}
|
||||
|
||||
func allTools() []Tool {
|
||||
return []Tool{Unknown, TaskDone, CodeComment, FileRead, FileFind, FileReadDiff, FileSearch, CodeSearch}
|
||||
}
|
||||
|
||||
// IsKnown reports whether the tool is not UNKNOWN.
|
||||
func (t Tool) IsKnown() bool {
|
||||
return t != Unknown
|
||||
}
|
||||
|
||||
// LookupResult holds the result of a single tool lookup.
|
||||
type LookupResult struct {
|
||||
Result string
|
||||
Found bool
|
||||
}
|
||||
|
||||
// Provider is the interface that all concrete tool implementations satisfy.
|
||||
// Each tool handles one specific capability (read file, search code, etc.).
|
||||
type Provider interface {
|
||||
// Tool returns which tool this provider implements.
|
||||
Tool() Tool
|
||||
// Execute runs the tool with the given arguments and returns the result string.
|
||||
Execute(args map[string]any) (string, error)
|
||||
}
|
||||
|
||||
// Registry maps tool aliases to their providers. Users register their own implementations here.
|
||||
type Registry map[string]Provider
|
||||
|
||||
// NewRegistry creates an empty registry.
|
||||
func NewRegistry() Registry {
|
||||
return make(Registry)
|
||||
}
|
||||
|
||||
// Register adds a tool provider to the registry.
|
||||
func (r Registry) Register(p Provider) {
|
||||
r[p.Tool().Alias] = p
|
||||
}
|
||||
|
||||
// Lookup finds a provider by alias. Returns a zero-value LookupResult if not found.
|
||||
func (r Registry) Lookup(alias string) LookupResult {
|
||||
p, ok := r[alias]
|
||||
if !ok {
|
||||
return LookupResult{Found: false}
|
||||
}
|
||||
return LookupResult{Result: p.Tool().Alias, Found: true}
|
||||
}
|
||||
|
||||
// ErrToolNotFound is returned when a tool alias cannot be resolved.
|
||||
var ErrToolNotFound = fmt.Errorf("tool not found")
|
||||
|
||||
// NotAvailableError is the standard message returned when a tool is not registered.
|
||||
const NotAvailableMsg = "Error: Tool not found. The tool you attempted to call does not exist or is not available. Please check the tool name and try again with a valid tool."
|
||||
23
internal/tool/response_message.go
Normal file
23
internal/tool/response_message.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package tool
|
||||
|
||||
// ToolCallResult holds a single tool call and its execution result.
|
||||
type ToolCallResult struct {
|
||||
ToolCallID string // OpenAI-compatible tool call ID
|
||||
Name string // tool name (alias)
|
||||
Result string // output from the tool
|
||||
}
|
||||
|
||||
// TaskCheckpoint mirrors the Java TaskCheckPoint — signals completion or carries data back to the LLM.
|
||||
type TaskCheckpoint struct {
|
||||
Data string
|
||||
Completed bool
|
||||
}
|
||||
|
||||
// Complete returns a checkpoint signaling task completion.
|
||||
func Complete() TaskCheckpoint { return TaskCheckpoint{Completed: true} }
|
||||
|
||||
// Of returns a checkpoint with data.
|
||||
func Of(data string) TaskCheckpoint { return TaskCheckpoint{Data: data, Completed: false} }
|
||||
|
||||
const CommentSucceed = "Successfully commented."
|
||||
const ToolNotFoundMsg = "Error: Tool not found. The tool you attempted to call does not exist or is not available. Please check the tool name and try again with a valid tool."
|
||||
28
internal/tool/stub.go
Normal file
28
internal/tool/stub.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package tool
|
||||
|
||||
// StubProvider is a no-op tool provider that returns "not available" for all tools.
|
||||
// Useful as a fallback when users haven't registered real implementations.
|
||||
type StubProvider struct {
|
||||
tool Tool
|
||||
}
|
||||
|
||||
func NewStub(t Tool) *StubProvider { return &StubProvider{tool: t} }
|
||||
|
||||
func (s *StubProvider) Tool() Tool { return s.tool }
|
||||
|
||||
func (s *StubProvider) Execute(args map[string]any) (string, error) {
|
||||
return NotAvailableMsg, nil
|
||||
}
|
||||
|
||||
// BuiltinToolProvider implements tools that don't require external system access.
|
||||
type BuiltinToolProvider struct {
|
||||
tool Tool
|
||||
fn func(args map[string]any) (string, error)
|
||||
}
|
||||
|
||||
func NewBuiltin(t Tool, fn func(args map[string]any) (string, error)) *BuiltinToolProvider {
|
||||
return &BuiltinToolProvider{tool: t, fn: fn}
|
||||
}
|
||||
|
||||
func (b *BuiltinToolProvider) Tool() Tool { return b.tool }
|
||||
func (b *BuiltinToolProvider) Execute(args map[string]any) (string, error) { return b.fn(args) }
|
||||
Loading…
Add table
Add a link
Reference in a new issue