commit 484121b92b64b728ba06a24e00c435e9665e8ef3 Author: kite Date: Wed Apr 15 11:48:03 2026 +0800 feat: init diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8e168e3 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e13263 --- /dev/null +++ b/README.md @@ -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 diff --git a/cmd/argus/flags.go b/cmd/argus/flags.go new file mode 100644 index 0000000..c626db1 --- /dev/null +++ b/cmd/argus/flags.go @@ -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`) +} diff --git a/cmd/argus/git.go b/cmd/argus/git.go new file mode 100644 index 0000000..1f60e59 --- /dev/null +++ b/cmd/argus/git.go @@ -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() +} diff --git a/cmd/argus/main.go b/cmd/argus/main.go new file mode 100644 index 0000000..99fcab1 --- /dev/null +++ b/cmd/argus/main.go @@ -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 +} diff --git a/cmd/argus/output.go b/cmd/argus/output.go new file mode 100644 index 0000000..fd0cdc2 --- /dev/null +++ b/cmd/argus/output.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6106b56 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/argus-review/argus + +go 1.24.4 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..3bbcb3a --- /dev/null +++ b/internal/agent/agent.go @@ -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("\n", i, m.Role)) + sb.WriteString(" \n") + sb.WriteString(fmt.Sprintf(" %s\n", m.Content)) + sb.WriteString(" \n") + sb.WriteString("") + if i < len(msgs)-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/config/example_config.yaml b/internal/config/example_config.yaml new file mode 100644 index 0000000..f9c3ac8 --- /dev/null +++ b/internal/config/example_config.yaml @@ -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}} + + 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 diff --git a/internal/config/example_tools.go b/internal/config/example_tools.go new file mode 100644 index 0000000..dc8e518 --- /dev/null +++ b/internal/config/example_tools.go @@ -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 +} diff --git a/internal/config/template.go b/internal/config/template.go new file mode 100644 index 0000000..66d9161 --- /dev/null +++ b/internal/config/template.go @@ -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"` +} diff --git a/internal/diff/git.go b/internal/diff/git.go new file mode 100644 index 0000000..7fe8a1d --- /dev/null +++ b/internal/diff/git.go @@ -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 +} diff --git a/internal/diff/parser.go b/internal/diff/parser.go new file mode 100644 index 0000000..dcbe22d --- /dev/null +++ b/internal/diff/parser.go @@ -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) + } +} diff --git a/internal/llm/client.go b/internal/llm/client.go new file mode 100644 index 0000000..802d64e --- /dev/null +++ b/internal/llm/client.go @@ -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, "", ""), "", "") + 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() +} diff --git a/internal/model/diff.go b/internal/model/diff.go new file mode 100644 index 0000000..c10ee24 --- /dev/null +++ b/internal/model/diff.go @@ -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"` +} diff --git a/internal/tool/definitions.go b/internal/tool/definitions.go new file mode 100644 index 0000000..b907adb --- /dev/null +++ b/internal/tool/definitions.go @@ -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." diff --git a/internal/tool/response_message.go b/internal/tool/response_message.go new file mode 100644 index 0000000..55c87b1 --- /dev/null +++ b/internal/tool/response_message.go @@ -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." diff --git a/internal/tool/stub.go b/internal/tool/stub.go new file mode 100644 index 0000000..6b381ae --- /dev/null +++ b/internal/tool/stub.go @@ -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) }