feat: init

This commit is contained in:
kite 2026-04-15 11:48:03 +08:00
commit 484121b92b
19 changed files with 1789 additions and 0 deletions

29
Makefile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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

View 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
}

View 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
View 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
View 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
View 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
View 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"`
}

View 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."

View 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
View 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) }