From 64323c621db461532332b5dfeaa89be69ca93053 Mon Sep 17 00:00:00 2001
From: css521 <1156986964@qq.com>
Date: Wed, 10 Jun 2026 16:16:07 +0800
Subject: [PATCH] feat: add ocr scan for full-file code review
Introduce a new top-level subcommand `ocr scan` (alias `s`) that reviews
whole files instead of git diffs. Use cases include reviewing unfamiliar
codebases, pre-migration audits, and ad-hoc per-directory reviews.
Architecture splits scan and diff review at the package level so the two
pipelines can evolve independently:
- internal/scan/ new package: file enumeration via `git ls-files`,
full-scan agent, FULL_SCAN_TASK rendering, preview
- internal/llmloop/ new package: shared LLM tool-use loop, three-zone
memory compression, CommentWorkerPool, AgentWarning.
Both internal/agent and internal/scan delegate to
llmloop.Runner; agent and scan never import each other
- internal/agent/ slimmed: LLM loop / compression / token aggregation
moved to llmloop; review-only orchestration remains
- internal/model/ new ScanItem (full-file payload) + Preview /
PreviewEntry / ExcludeReason shared by both modes
- internal/diff/ new gitignore.go exporting helpers reused by scan
- cmd/opencodereview/ new scan_cmd.go; shared.go consolidates startup
(loadCommonContext / loadLLMRuntime), output
(emitRunResult, ResultProvider) and stdout silencing
(quietHandle); review_cmd.go follows the same shape
Template additions:
- FULL_SCAN_TASK: dedicated prompt with Tool-call discipline guidance to
reduce gratuitous tool calls per file
- FULL_SCAN_MAX_TOOL_REQUEST_TIMES (default 60): scan-only per-file budget,
raised over diff's 30 to fit multi-finding files; --max-tools still
composes (only raise, never lower)
In scan mode, file_read_diff is filtered out of MainToolDefs since it has
no useful semantics without a diff.
Tests cover provider enumeration (with temp git repo), template rendering,
filter passes, dependency budget, flag validation, and excludeToolDef.
---
cmd/opencodereview/main.go | 8 +-
cmd/opencodereview/output.go | 2 +
cmd/opencodereview/review_cmd.go | 151 +---
cmd/opencodereview/scan_cmd.go | 237 ++++++
cmd/opencodereview/scan_cmd_test.go | 145 ++++
cmd/opencodereview/shared.go | 226 ++++++
internal/agent/agent.go | 752 ++------------------
internal/agent/preview.go | 44 +-
internal/config/template/task_template.json | 14 +
internal/config/template/template.go | 17 +-
internal/config/template/template_test.go | 37 +
internal/diff/git.go | 14 +-
internal/diff/gitignore.go | 33 +
internal/llmloop/compression.go | 310 ++++++++
internal/llmloop/loop.go | 404 +++++++++++
internal/llmloop/pool.go | 74 ++
internal/model/preview.go | 35 +
internal/model/scan.go | 30 +
internal/scan/agent.go | 418 +++++++++++
internal/scan/agent_test.go | 185 +++++
internal/scan/preview.go | 43 ++
internal/scan/provider.go | 226 ++++++
internal/scan/provider_test.go | 194 +++++
internal/session/history.go | 1 +
24 files changed, 2741 insertions(+), 859 deletions(-)
create mode 100644 cmd/opencodereview/scan_cmd.go
create mode 100644 cmd/opencodereview/scan_cmd_test.go
create mode 100644 cmd/opencodereview/shared.go
create mode 100644 internal/config/template/template_test.go
create mode 100644 internal/diff/gitignore.go
create mode 100644 internal/llmloop/compression.go
create mode 100644 internal/llmloop/loop.go
create mode 100644 internal/llmloop/pool.go
create mode 100644 internal/model/preview.go
create mode 100644 internal/model/scan.go
create mode 100644 internal/scan/agent.go
create mode 100644 internal/scan/agent_test.go
create mode 100644 internal/scan/preview.go
create mode 100644 internal/scan/provider.go
create mode 100644 internal/scan/provider_test.go
diff --git a/cmd/opencodereview/main.go b/cmd/opencodereview/main.go
index d97d9c9..06aa630 100644
--- a/cmd/opencodereview/main.go
+++ b/cmd/opencodereview/main.go
@@ -46,6 +46,8 @@ func dispatch() error {
return nil
case "review", "r":
return runReview(args[1:])
+ case "scan", "s":
+ return runScan(args[1:])
case "config":
return runConfig(args[1:])
case "llm":
@@ -69,7 +71,8 @@ Usage:
ocr [command]
Commands:
- review, r Start a code review
+ review, r Start a diff-based code review
+ scan, s Scan entire files (no diff required)
rules Inspect and debug review rules
config Manage configuration settings
llm LLM utility commands
@@ -79,11 +82,14 @@ Commands:
Examples:
ocr review --from master --to dev Review diff range
ocr review --commit abc123 Review a single commit
+ ocr scan --all Scan every reviewable file in the repo
+ ocr scan --path internal/agent Scan a single directory
ocr config set llm.model opus-4-6 Set a config value
ocr llm test Test LLM connectivity
ocr version Show version info
Use "ocr review -h" for more information about review.
+Use "ocr scan -h" for more information about scan.
Use "ocr rules -h" for more information about rules.
Use "ocr config" for more information about config.
Use "ocr llm" for more information about LLM utilities.
diff --git a/cmd/opencodereview/output.go b/cmd/opencodereview/output.go
index 9bbd980..98343b5 100644
--- a/cmd/opencodereview/output.go
+++ b/cmd/opencodereview/output.go
@@ -299,6 +299,8 @@ func statusBadge(status string) string {
return "\033[36m[R]\033[0m"
case "binary":
return "\033[35m[B]\033[0m"
+ case "scan":
+ return "\033[34m[S]\033[0m"
default:
return "[?]"
}
diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go
index 400c1c2..ed9869d 100644
--- a/cmd/opencodereview/review_cmd.go
+++ b/cmd/opencodereview/review_cmd.go
@@ -8,13 +8,6 @@ import (
"time"
"github.com/open-code-review/open-code-review/internal/agent"
- "github.com/open-code-review/open-code-review/internal/config/rules"
- "github.com/open-code-review/open-code-review/internal/config/template"
- "github.com/open-code-review/open-code-review/internal/config/toolsconfig"
- "github.com/open-code-review/open-code-review/internal/diff"
- "github.com/open-code-review/open-code-review/internal/gitcmd"
- "github.com/open-code-review/open-code-review/internal/llm"
- "github.com/open-code-review/open-code-review/internal/stdout"
"github.com/open-code-review/open-code-review/internal/telemetry"
"github.com/open-code-review/open-code-review/internal/tool"
)
@@ -22,120 +15,69 @@ import (
func runReview(args []string) error {
opts, err := parseReviewFlags(args)
if err != nil {
- return fmt.Errorf("parse flags: %w", err)
+ // parseReviewFlags already wraps with "parse flags: %w" — return as-is.
+ return err
}
if opts.showHelp {
printReviewUsage()
return nil
}
- if err := requireGitRepo(opts.repoDir); err != nil {
- return err
- }
-
- tpl, err := template.LoadDefault()
- if err != nil {
- return fmt.Errorf("load default template: %w", err)
- }
- if opts.maxTools > 0 {
- tpl.MaxToolRequestTimes = opts.maxTools
- }
- if err := tpl.Validate(); err != nil {
- return fmt.Errorf("invalid config: %w", err)
- }
-
- repoDir, err := resolveRepoDir(opts.repoDir)
+ cc, err := loadCommonContext(opts.repoDir, opts.rulePath, opts.maxTools, opts.maxGitProcs)
if err != nil {
- return fmt.Errorf("resolve repo: %w", err)
+ return err
}
if opts.commit != "" && opts.background == "" {
- if msg, err := getCommitMessage(repoDir, opts.commit); err == nil && msg != "" {
+ if msg, err := getCommitMessage(cc.RepoDir, opts.commit); err == nil && msg != "" {
opts.background = msg
}
}
- resolver, fileFilter, err := rules.NewResolver(repoDir, opts.rulePath)
- if err != nil {
- return fmt.Errorf("load rules: %w", err)
- }
-
if opts.preview {
- return runPreview(repoDir, opts, fileFilter)
- }
-
- toolEntries, err := toolsconfig.Load(opts.toolConfigPath)
- if err != nil {
- return fmt.Errorf("load tools: %w", err)
+ return runPreview(cc, opts)
}
- planToolDefs := agent.BuildToolDefs(toolEntries, true)
- mainToolDefs := agent.BuildToolDefs(toolEntries, false)
- cfgPath, err := defaultConfigPath()
+ rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath)
if err != nil {
return err
}
- appCfg, err := LoadAppConfig(cfgPath)
- if err != nil {
- return fmt.Errorf("load app config: %w", err)
- }
- if appCfg != nil {
- tpl.ApplyLanguage(appCfg.Language)
- }
-
- ep, err := llm.ResolveEndpoint(cfgPath)
- if err != nil {
- return fmt.Errorf("resolve LLM endpoint: %w", err)
- }
-
- llmClient := llm.NewLLMClient(ep)
- model := ep.Model
-
- gitRunner := gitcmd.New(opts.maxGitProcs)
-
- collector := tool.NewCommentCollector()
mode := tool.ParseReviewMode(opts.from, opts.to, opts.commit)
ref, _ := mode.RefValue(opts.to, opts.commit)
fileReader := &tool.FileReader{
- RepoDir: repoDir,
+ RepoDir: cc.RepoDir,
Mode: mode,
Ref: ref,
- Runner: gitRunner,
+ Runner: cc.GitRunner,
}
- tools := buildToolRegistry(collector, fileReader)
+ tools := buildToolRegistry(rt.Collector, fileReader)
ag := agent.New(agent.Args{
- RepoDir: repoDir,
+ RepoDir: cc.RepoDir,
From: opts.from,
To: opts.to,
Commit: opts.commit,
- Template: *tpl,
- SystemRule: resolver,
- FileFilter: fileFilter,
- LLMClient: llmClient,
+ Template: *cc.Template,
+ SystemRule: cc.Resolver,
+ FileFilter: cc.FileFilter,
+ LLMClient: rt.Client,
Tools: tools,
- PlanToolDefs: planToolDefs,
- MainToolDefs: mainToolDefs,
- CommentCollector: collector,
+ PlanToolDefs: rt.PlanToolDefs,
+ MainToolDefs: rt.MainToolDefs,
+ CommentCollector: rt.Collector,
CommentWorkerPool: agent.NewCommentWorkerPool(opts.concurrency),
MaxConcurrency: opts.concurrency,
ConcurrentTaskTimeout: opts.perFileTimeout,
- Model: model,
+ Model: rt.Model,
Background: opts.background,
- GitRunner: gitRunner,
+ GitRunner: cc.GitRunner,
})
- // Silence progress output during execution; restore before Summary in agent mode.
- var unsilence func()
- if opts.outputFormat == "json" || opts.audience == "agent" {
- unsilence = stdout.Quiet()
- defer func() {
- if unsilence != nil {
- unsilence()
- }
- }()
- }
+ // Silence progress output during execution; restored before the trace
+ // summary in agent-text mode (and on function exit otherwise).
+ q := newQuietHandle(opts.outputFormat, opts.audience)
+ defer q.Restore()
ctx, span := telemetry.StartSpan(context.Background(), "review.run")
defer span.End()
@@ -147,41 +89,7 @@ func runReview(args []string) error {
return fmt.Errorf("review failed: %w", err)
}
- // Resolve line numbers by matching existing_code against diff hunks.
- comments = diff.ResolveLineNumbers(comments, ag.Diffs())
-
- // Record summary metrics (files_reviewed is refined by agent.Run).
- duration := time.Since(startTime)
- telemetry.RecordReviewDuration(ctx, duration)
- if len(comments) > 0 {
- telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
- }
-
- // If no files were reviewed (e.g. workspace has no changes), inform the caller in JSON mode.
- if opts.outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 {
- return outputJSONNoFiles()
- }
-
- // In agent mode (text output), restore stdout so Summary reaches the terminal.
- if opts.audience == "agent" && opts.outputFormat != "json" && unsilence != nil {
- unsilence()
- unsilence = nil
- }
-
- if opts.outputFormat != "json" {
- telemetry.PrintTraceSummary(ag.FilesReviewed(), int64(len(comments)), ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(), ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration)
- }
-
- if opts.outputFormat == "json" {
- return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(), ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(), ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration)
- }
- if opts.audience == "agent" {
- outputTextWithWarnings(comments, ag.Warnings())
- return nil
- }
- outputTextWithWarnings(comments, ag.Warnings())
-
- return nil
+ return emitRunResult(ctx, ag, comments, startTime, opts.outputFormat, opts.audience, q)
}
func resolveRepoDir(input string) (string, error) {
@@ -216,15 +124,14 @@ func requireGitRepo(dir string) error {
return nil
}
-func runPreview(repoDir string, opts reviewOptions, fileFilter *rules.FileFilter) error {
- gitRunner := gitcmd.New(opts.maxGitProcs)
+func runPreview(cc *commonContext, opts reviewOptions) error {
ag := agent.New(agent.Args{
- RepoDir: repoDir,
+ RepoDir: cc.RepoDir,
From: opts.from,
To: opts.to,
Commit: opts.commit,
- FileFilter: fileFilter,
- GitRunner: gitRunner,
+ FileFilter: cc.FileFilter,
+ GitRunner: cc.GitRunner,
})
preview, err := ag.Preview(context.Background())
diff --git a/cmd/opencodereview/scan_cmd.go b/cmd/opencodereview/scan_cmd.go
new file mode 100644
index 0000000..7112fbf
--- /dev/null
+++ b/cmd/opencodereview/scan_cmd.go
@@ -0,0 +1,237 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/open-code-review/open-code-review/internal/llmloop"
+ "github.com/open-code-review/open-code-review/internal/scan"
+ "github.com/open-code-review/open-code-review/internal/telemetry"
+ "github.com/open-code-review/open-code-review/internal/tool"
+)
+
+// scanOptions mirrors reviewOptions for the full-scan subcommand. The two
+// types are kept separate so the scan flag set can evolve independently of
+// the diff-based review flags (e.g. --from/--to/--commit make no sense here).
+type scanOptions struct {
+ toolConfigPath string
+ rulePath string
+ repoDir string
+ all bool
+ paths string // comma-separated relative paths
+ outputFormat string
+ audience string
+ background string
+ concurrency int
+ perFileTimeout int
+ maxTools int
+ maxGitProcs int
+ preview bool
+ showHelp bool
+}
+
+func parseScanFlags(args []string) (scanOptions, error) {
+ a := newOcrFlagSet("ocr scan")
+ opts := scanOptions{}
+
+ a.StringVar(&opts.toolConfigPath, "tools", "", "path to JSON tools config file (default: embedded)")
+ a.StringVar(&opts.rulePath, "rule", "", "path to JSON file with system review rules")
+ a.StringVar(&opts.repoDir, "repo", "", "root directory of the git repository (default: current dir)")
+ a.BoolVar(&opts.all, "all", false, "scan every reviewable file in the repository")
+ a.StringVar(&opts.paths, "path", "", "comma-separated repo-relative directories or files to scan")
+ a.StringVarP(&opts.outputFormat, "format", "f", "text", "output format: text or json")
+ a.IntVar(&opts.concurrency, "concurrency", 8, "max concurrent file scans")
+ a.IntVar(&opts.perFileTimeout, "timeout", 10, "concurrent task timeout in minutes")
+ a.StringVar(&opts.audience, "audience", "human", "output audience: human (show progress) or agent (summary only)")
+ a.StringVarP(&opts.background, "background", "b", "", "optional requirement/business context for the scan")
+ a.IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file; only takes effect when greater than template default")
+ a.IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses")
+ a.BoolVarP(&opts.preview, "preview", "p", false, "preview which files will be scanned without running the LLM")
+
+ if err := a.Parse(args); err != nil {
+ return opts, fmt.Errorf("parse flags: %w", err)
+ }
+
+ opts.showHelp = a.showHelp
+ if opts.showHelp {
+ return opts, nil
+ }
+
+ if !opts.all && strings.TrimSpace(opts.paths) == "" {
+ return opts, fmt.Errorf("must specify --all or --path; run 'ocr scan -h' for usage")
+ }
+
+ switch opts.audience {
+ case "human", "agent":
+ default:
+ return opts, fmt.Errorf("invalid --audience value %q: must be 'human' or 'agent'", opts.audience)
+ }
+
+ if opts.maxTools < 0 {
+ return opts, fmt.Errorf("--max-tools must be a non-negative integer (0 means use template default)")
+ }
+ if opts.maxGitProcs < 0 {
+ return opts, fmt.Errorf("--max-git-procs must be a non-negative integer (0 means use default 16)")
+ }
+ return opts, nil
+}
+
+func splitPaths(raw string) []string {
+ if raw == "" {
+ return nil
+ }
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func runScan(args []string) error {
+ opts, err := parseScanFlags(args)
+ if err != nil {
+ // parseScanFlags already wraps with "parse flags: %w" — return as-is.
+ return err
+ }
+ if opts.showHelp {
+ printScanUsage()
+ return nil
+ }
+
+ cc, err := loadCommonContext(opts.repoDir, opts.rulePath, opts.maxTools, opts.maxGitProcs)
+ if err != nil {
+ return err
+ }
+ if cc.Template.FullScanTask == nil || len(cc.Template.FullScanTask.Messages) == 0 {
+ return fmt.Errorf("FULL_SCAN_TASK is missing from the loaded template")
+ }
+
+ // Scan reviews whole files (often hundreds of lines with multiple
+ // findings), so it needs a larger per-file tool-call budget than diff
+ // review. Promote MaxToolRequestTimes to the scan-specific value. The
+ // --max-tools flag (handled in loadCommonContext) can still raise this
+ // further; we only raise, never lower, so an explicit --max-tools
+ // override wins.
+ if cc.Template.FullScanMaxToolRequestTimes > cc.Template.MaxToolRequestTimes {
+ cc.Template.MaxToolRequestTimes = cc.Template.FullScanMaxToolRequestTimes
+ }
+
+ scanPaths := splitPaths(opts.paths)
+
+ if opts.preview {
+ return runScanPreview(cc, scanPaths)
+ }
+
+ rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath)
+ if err != nil {
+ return err
+ }
+
+ // file_read_diff is meaningless in scan mode (no diff exists). Hiding it
+ // from MainToolDefs stops the LLM from burning tool-call rounds probing
+ // for diff content that does not exist.
+ scanToolDefs := excludeToolDef(rt.MainToolDefs, "file_read_diff")
+
+ // Scan mode always reads file contents from the working tree.
+ fileReader := &tool.FileReader{
+ RepoDir: cc.RepoDir,
+ Mode: tool.ModeWorkspace,
+ Runner: cc.GitRunner,
+ }
+ tools := buildToolRegistry(rt.Collector, fileReader)
+
+ ag := scan.NewAgent(scan.Args{
+ RepoDir: cc.RepoDir,
+ Paths: scanPaths,
+ Template: *cc.Template,
+ SystemRule: cc.Resolver,
+ FileFilter: cc.FileFilter,
+ LLMClient: rt.Client,
+ Tools: tools,
+ MainToolDefs: scanToolDefs,
+ CommentCollector: rt.Collector,
+ CommentWorkerPool: llmloop.NewCommentWorkerPool(opts.concurrency),
+ MaxConcurrency: opts.concurrency,
+ ConcurrentTaskTimeout: opts.perFileTimeout,
+ Model: rt.Model,
+ Background: opts.background,
+ GitRunner: cc.GitRunner,
+ })
+
+ q := newQuietHandle(opts.outputFormat, opts.audience)
+ defer q.Restore()
+
+ ctx, span := telemetry.StartSpan(context.Background(), "scan.run")
+ defer span.End()
+ startTime := time.Now()
+
+ comments, err := ag.Run(ctx)
+ if err != nil {
+ telemetry.SetAttr(span, "error", err.Error())
+ return fmt.Errorf("scan failed: %w", err)
+ }
+
+ return emitRunResult(ctx, ag, comments, startTime, opts.outputFormat, opts.audience, q)
+}
+
+func runScanPreview(cc *commonContext, scanPaths []string) error {
+ ag := scan.NewAgent(scan.Args{
+ RepoDir: cc.RepoDir,
+ Paths: scanPaths,
+ FileFilter: cc.FileFilter,
+ GitRunner: cc.GitRunner,
+ // Template is unused by Preview but NewAgent inspects nothing in it.
+ })
+
+ preview, err := ag.Preview(context.Background())
+ if err != nil {
+ return fmt.Errorf("scan preview failed: %w", err)
+ }
+ outputPreviewText(preview)
+ return nil
+}
+
+func printScanUsage() {
+ fmt.Println(`OpenCodeReview - Full-File Scan
+
+Usage:
+ ocr scan [flags]
+ ocr s [flags] (alias)
+
+Examples:
+ # Scan the entire repository
+ ocr scan --all
+
+ # Scan a single directory
+ ocr scan --path internal/agent
+
+ # Scan multiple files
+ ocr scan --path internal/agent/agent.go,internal/diff/scan.go
+
+ # Combine --all with --path to restrict the all-scan
+ ocr scan --all --path internal/
+
+ # Preview which files would be scanned without calling the LLM
+ ocr scan --all --preview
+
+Flags:
+ --all scan every reviewable file in the repository
+ --path string comma-separated repo-relative dirs/files to scan
+ --audience string output audience: human (show progress) or agent (summary only) (default "human")
+ -b, --background string optional requirement/business context for the scan
+ -f, --format string output format: text or json (default "text")
+ --concurrency int max concurrent file scans (default 8)
+ --max-git-procs int max concurrent git subprocesses (default 16)
+ --max-tools int max tool call rounds per file; only takes effect when greater than template default
+ -p, --preview preview which files will be scanned without running the LLM
+ --repo string root directory of the git repository (default: current dir)
+ --rule string path to JSON file with system review rules
+ --timeout int concurrent task timeout in minutes (default 10)
+ --tools string path to JSON tools config file (default: embedded)`)
+}
diff --git a/cmd/opencodereview/scan_cmd_test.go b/cmd/opencodereview/scan_cmd_test.go
new file mode 100644
index 0000000..fa69850
--- /dev/null
+++ b/cmd/opencodereview/scan_cmd_test.go
@@ -0,0 +1,145 @@
+package main
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/llm"
+)
+
+func TestExcludeToolDef(t *testing.T) {
+ defs := []llm.ToolDef{
+ {Type: "function", Function: llm.FunctionDef{Name: "task_done"}},
+ {Type: "function", Function: llm.FunctionDef{Name: "file_read"}},
+ {Type: "function", Function: llm.FunctionDef{Name: "file_read_diff"}},
+ {Type: "function", Function: llm.FunctionDef{Name: "code_comment"}},
+ }
+ got := excludeToolDef(defs, "file_read_diff")
+ if len(got) != 3 {
+ t.Fatalf("expected 3 defs, got %d", len(got))
+ }
+ for _, d := range got {
+ if d.Function.Name == "file_read_diff" {
+ t.Errorf("file_read_diff should have been removed")
+ }
+ }
+ // Input slice must not be mutated.
+ if len(defs) != 4 {
+ t.Errorf("input slice was mutated: len=%d, want 4", len(defs))
+ }
+}
+
+func TestExcludeToolDef_AbsentName(t *testing.T) {
+ defs := []llm.ToolDef{
+ {Type: "function", Function: llm.FunctionDef{Name: "task_done"}},
+ }
+ got := excludeToolDef(defs, "does_not_exist")
+ if !reflect.DeepEqual(got, defs) {
+ t.Errorf("removing absent name should return identical content")
+ }
+}
+
+func TestSplitPaths(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want []string
+ }{
+ {"empty", "", nil},
+ {"single", "internal/agent", []string{"internal/agent"}},
+ {"multiple", "a.go,b.go,c.go", []string{"a.go", "b.go", "c.go"}},
+ {"trims whitespace", " a.go , b.go ", []string{"a.go", "b.go"}},
+ {"drops empty segments", "a.go,,b.go,", []string{"a.go", "b.go"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := splitPaths(tt.in)
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("splitPaths(%q) = %v, want %v", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestParseScanFlags_RequiresAllOrPath(t *testing.T) {
+ _, err := parseScanFlags([]string{}) // no flags
+ if err == nil {
+ t.Fatal("expected error when neither --all nor --path is supplied")
+ }
+ if !strings.Contains(err.Error(), "must specify --all or --path") {
+ t.Errorf("error message = %q; want it to mention --all/--path", err.Error())
+ }
+}
+
+func TestParseScanFlags_RejectsInvalidAudience(t *testing.T) {
+ _, err := parseScanFlags([]string{"--all", "--audience", "robot"})
+ if err == nil {
+ t.Fatal("expected error for invalid --audience")
+ }
+ if !strings.Contains(err.Error(), "invalid --audience") {
+ t.Errorf("error message = %q; want invalid --audience", err.Error())
+ }
+}
+
+func TestParseScanFlags_RejectsNegativeMaxTools(t *testing.T) {
+ _, err := parseScanFlags([]string{"--all", "--max-tools", "-1"})
+ if err == nil {
+ t.Fatal("expected error for negative --max-tools")
+ }
+ if !strings.Contains(err.Error(), "--max-tools") {
+ t.Errorf("error message = %q; want it to mention --max-tools", err.Error())
+ }
+}
+
+func TestParseScanFlags_RejectsNegativeMaxGitProcs(t *testing.T) {
+ _, err := parseScanFlags([]string{"--all", "--max-git-procs", "-3"})
+ if err == nil {
+ t.Fatal("expected error for negative --max-git-procs")
+ }
+ if !strings.Contains(err.Error(), "--max-git-procs") {
+ t.Errorf("error message = %q; want it to mention --max-git-procs", err.Error())
+ }
+}
+
+func TestParseScanFlags_AllSetsValid(t *testing.T) {
+ opts, err := parseScanFlags([]string{"--all"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !opts.all || opts.paths != "" {
+ t.Errorf("opts = %+v; want all=true paths=\"\"", opts)
+ }
+ if opts.audience != "human" {
+ t.Errorf("default audience = %q, want \"human\"", opts.audience)
+ }
+ if opts.outputFormat != "text" {
+ t.Errorf("default outputFormat = %q, want \"text\"", opts.outputFormat)
+ }
+ if opts.concurrency != 8 {
+ t.Errorf("default concurrency = %d, want 8", opts.concurrency)
+ }
+}
+
+func TestParseScanFlags_PathOnlyIsValid(t *testing.T) {
+ opts, err := parseScanFlags([]string{"--path", "internal/agent,internal/diff"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if opts.all {
+ t.Errorf("opts.all = true, want false")
+ }
+ if got := splitPaths(opts.paths); !reflect.DeepEqual(got, []string{"internal/agent", "internal/diff"}) {
+ t.Errorf("splitPaths(opts.paths) = %v", got)
+ }
+}
+
+func TestParseScanFlags_HelpFlag(t *testing.T) {
+ opts, err := parseScanFlags([]string{"-h"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !opts.showHelp {
+ t.Error("opts.showHelp should be true when -h is supplied")
+ }
+}
diff --git a/cmd/opencodereview/shared.go b/cmd/opencodereview/shared.go
new file mode 100644
index 0000000..e7244ec
--- /dev/null
+++ b/cmd/opencodereview/shared.go
@@ -0,0 +1,226 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/open-code-review/open-code-review/internal/agent"
+ "github.com/open-code-review/open-code-review/internal/config/rules"
+ "github.com/open-code-review/open-code-review/internal/config/template"
+ "github.com/open-code-review/open-code-review/internal/config/toolsconfig"
+ "github.com/open-code-review/open-code-review/internal/diff"
+ "github.com/open-code-review/open-code-review/internal/gitcmd"
+ "github.com/open-code-review/open-code-review/internal/llm"
+ "github.com/open-code-review/open-code-review/internal/model"
+ "github.com/open-code-review/open-code-review/internal/stdout"
+ "github.com/open-code-review/open-code-review/internal/telemetry"
+ "github.com/open-code-review/open-code-review/internal/tool"
+)
+
+// commonContext bundles the state that both `ocr review` and `ocr scan`
+// need to load *before* deciding whether to dispatch a preview or a real
+// LLM session: a validated template, the resolved repo path, review rules,
+// and a shared git subprocess limiter.
+type commonContext struct {
+ Template *template.Template
+ RepoDir string
+ Resolver rules.Resolver
+ FileFilter *rules.FileFilter
+ GitRunner *gitcmd.Runner
+}
+
+// loadCommonContext validates the working directory, loads the embedded
+// template, raises MaxToolRequestTimes when maxTools exceeds the default,
+// resolves the absolute repo path, loads system review rules, and creates
+// the global git subprocess limiter. Both review and scan callers go
+// through this so the startup sequence stays consistent.
+func loadCommonContext(repoDirInput, rulePath string, maxTools, maxGitProcs int) (*commonContext, error) {
+ if err := requireGitRepo(repoDirInput); err != nil {
+ return nil, err
+ }
+
+ tpl, err := template.LoadDefault()
+ if err != nil {
+ return nil, fmt.Errorf("load default template: %w", err)
+ }
+ if maxTools > tpl.MaxToolRequestTimes {
+ tpl.MaxToolRequestTimes = maxTools
+ }
+ if err := tpl.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid config: %w", err)
+ }
+
+ repoDir, err := resolveRepoDir(repoDirInput)
+ if err != nil {
+ return nil, fmt.Errorf("resolve repo: %w", err)
+ }
+
+ resolver, fileFilter, err := rules.NewResolver(repoDir, rulePath)
+ if err != nil {
+ return nil, fmt.Errorf("load rules: %w", err)
+ }
+
+ return &commonContext{
+ Template: tpl,
+ RepoDir: repoDir,
+ Resolver: resolver,
+ FileFilter: fileFilter,
+ GitRunner: gitcmd.New(maxGitProcs),
+ }, nil
+}
+
+// llmRuntime bundles the LLM-side state both subcommands need once they've
+// decided to actually run a session: tool definitions, an app-language
+// adjusted template (mutated in place via ApplyLanguage), the LLM client,
+// the resolved model name, and a fresh comment collector.
+type llmRuntime struct {
+ Client llm.LLMClient
+ Model string
+ PlanToolDefs []llm.ToolDef
+ MainToolDefs []llm.ToolDef
+ Collector *tool.CommentCollector
+ AppCfg *Config
+}
+
+// loadLLMRuntime loads tool defs from toolConfigPath, reads the app config
+// from the user's default config path (applying any configured language to
+// tpl), resolves the LLM endpoint, and returns the runtime bundle. tpl is
+// mutated in place when an app language is configured.
+func loadLLMRuntime(tpl *template.Template, toolConfigPath string) (*llmRuntime, error) {
+ toolEntries, err := toolsconfig.Load(toolConfigPath)
+ if err != nil {
+ return nil, fmt.Errorf("load tools: %w", err)
+ }
+ planToolDefs := agent.BuildToolDefs(toolEntries, true)
+ mainToolDefs := agent.BuildToolDefs(toolEntries, false)
+
+ cfgPath, err := defaultConfigPath()
+ if err != nil {
+ return nil, err
+ }
+ appCfg, err := LoadAppConfig(cfgPath)
+ if err != nil {
+ return nil, fmt.Errorf("load app config: %w", err)
+ }
+ if appCfg != nil {
+ tpl.ApplyLanguage(appCfg.Language)
+ }
+
+ ep, err := llm.ResolveEndpoint(cfgPath)
+ if err != nil {
+ return nil, fmt.Errorf("resolve LLM endpoint: %w", err)
+ }
+
+ return &llmRuntime{
+ Client: llm.NewLLMClient(ep),
+ Model: ep.Model,
+ PlanToolDefs: planToolDefs,
+ MainToolDefs: mainToolDefs,
+ Collector: tool.NewCommentCollector(),
+ AppCfg: appCfg,
+ }, nil
+}
+
+// excludeToolDef returns a copy of defs with any entries whose function name
+// matches name removed. Used by `ocr scan` to hide tools that don't make
+// sense in full-scan mode (e.g. file_read_diff).
+func excludeToolDef(defs []llm.ToolDef, name string) []llm.ToolDef {
+ out := make([]llm.ToolDef, 0, len(defs))
+ for _, d := range defs {
+ if d.Function.Name == name {
+ continue
+ }
+ out = append(out, d)
+ }
+ return out
+}
+
+// quietHandle wraps a stdout.Quiet() restorer so callers can `defer
+// q.Restore()` for safety while emitRunResult restores it early when the
+// agent-text audience needs the trace summary on the user's terminal.
+// Restore is idempotent.
+type quietHandle struct {
+ fn func()
+}
+
+// newQuietHandle silences stdout when outputFormat=="json" or
+// audience=="agent"; otherwise the returned handle is a no-op restorer.
+func newQuietHandle(outputFormat, audience string) *quietHandle {
+ h := &quietHandle{}
+ if outputFormat == "json" || audience == "agent" {
+ h.fn = stdout.Quiet()
+ }
+ return h
+}
+
+// Restore re-enables stdout. Safe to call multiple times.
+func (h *quietHandle) Restore() {
+ if h == nil || h.fn == nil {
+ return
+ }
+ h.fn()
+ h.fn = nil
+}
+
+// ResultProvider abstracts the metadata both internal/agent.Agent and
+// internal/scan.Agent expose post-run, so emitRunResult can finalize
+// either without knowing which kind it has.
+type ResultProvider interface {
+ Diffs() []model.Diff
+ FilesReviewed() int64
+ TotalInputTokens() int64
+ TotalOutputTokens() int64
+ TotalTokensUsed() int64
+ TotalCacheReadTokens() int64
+ TotalCacheWriteTokens() int64
+ Warnings() []agent.AgentWarning
+}
+
+// emitRunResult is the post-LLM-run finalization shared by `ocr review` and
+// `ocr scan`: resolves comment line numbers, records telemetry, restores
+// stdout early for agent-text audiences so the summary is visible, prints
+// the trace summary, and writes the result in the requested format.
+//
+// q is the silencing handle returned by newQuietHandle; pass nil if no
+// silencing was set up (in which case the early restore is a no-op).
+func emitRunResult(
+ ctx context.Context,
+ ag ResultProvider,
+ comments []model.LlmComment,
+ startTime time.Time,
+ outputFormat, audience string,
+ q *quietHandle,
+) error {
+ comments = diff.ResolveLineNumbers(comments, ag.Diffs())
+
+ duration := time.Since(startTime)
+ telemetry.RecordReviewDuration(ctx, duration)
+ if len(comments) > 0 {
+ telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
+ }
+
+ if outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 {
+ return outputJSONNoFiles()
+ }
+
+ // Agent-text audiences need stdout back before PrintTraceSummary so the
+ // summary line lands on their terminal.
+ if audience == "agent" && outputFormat != "json" {
+ q.Restore()
+ }
+
+ if outputFormat != "json" {
+ telemetry.PrintTraceSummary(ag.FilesReviewed(), int64(len(comments)),
+ ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
+ ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration)
+ }
+
+ if outputFormat == "json" {
+ return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(),
+ ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
+ ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration)
+ }
+ outputTextWithWarnings(comments, ag.Warnings())
+ return nil
+}
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index a71bc07..5f53951 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -17,6 +17,7 @@ import (
"github.com/open-code-review/open-code-review/internal/diff"
"github.com/open-code-review/open-code-review/internal/gitcmd"
"github.com/open-code-review/open-code-review/internal/llm"
+ "github.com/open-code-review/open-code-review/internal/llmloop"
"github.com/open-code-review/open-code-review/internal/model"
"github.com/open-code-review/open-code-review/internal/session"
"github.com/open-code-review/open-code-review/internal/stdout"
@@ -24,6 +25,18 @@ import (
"github.com/open-code-review/open-code-review/internal/tool"
)
+// AgentWarning is re-exported from llmloop for backwards compatibility with
+// existing callers (cmd/opencodereview/output.go).
+type AgentWarning = llmloop.AgentWarning
+
+// CommentWorkerPool is re-exported from llmloop for backwards compatibility.
+type CommentWorkerPool = llmloop.CommentWorkerPool
+
+// NewCommentWorkerPool delegates to llmloop.NewCommentWorkerPool.
+func NewCommentWorkerPool(workerCount int) *CommentWorkerPool {
+ return llmloop.NewCommentWorkerPool(workerCount)
+}
+
// planBlockPattern matches the optional "Review Plan" section in a MAIN_TASK
// template user message: a header line beginning with "### " whose text
// contains "Review Plan" or "审查计划" (with optional ASCII "(Optional)" /
@@ -59,6 +72,7 @@ type Args struct {
// ReviewMode is one of "workspace", "range", or "commit".
// When empty, it is derived from From/To/Commit at session creation time.
+ // Full-scan reviews are owned by internal/scan and never reach this Args.
ReviewMode string
// Template loaded from YAML config file.
@@ -121,109 +135,18 @@ type Args struct {
Session *session.SessionHistory
}
-// AgentWarning describes a non-fatal warning recorded during review.
-type AgentWarning struct {
- File string `json:"file"`
- Message string `json:"message"`
- Type string `json:"type"`
-}
-
-// compression thresholds as fractions of MaxTokens.
-const (
- tokenSoftThreshold = 0.60 // async background compression
- tokenWarningThreshold = 0.80 // immediate sync compression
-)
-
-// round groups consecutive messages starting with an assistant message
-// followed by zero or more tool result messages.
-type round struct {
- assistantIdx int
- toolIdxs []int
-}
-
-// partitionResult describes how messages should be split for compression.
-type partitionResult struct {
- frozenEnd int
- compressEnd int
- rounds []round
- activeCount int
-}
-
-// compressionJob tracks an in-flight background compression operation.
-type compressionJob struct {
- done chan struct{}
- rebuilt []llm.Message
- cancel context.CancelFunc
-}
-
-// Agent orchestrates the AI-powered code review.
+// Agent orchestrates the AI-powered code review. LLM tool-use loop / memory
+// compression / token aggregation now live in internal/llmloop.Runner; this
+// struct holds the diff-side state and orchestrates per-file subtasks.
type Agent struct {
- args Args
- diffs []model.Diff // parsed diffs
- totalInsertions int64
- totalDeletions int64
- currentDate string
- session *session.SessionHistory
- totalInputTokens int64 // accumulated input/prompt tokens, accessed atomically
- totalOutputTokens int64 // accumulated completion tokens, accessed atomically
- totalCacheReadTokens int64 // accumulated cache read tokens, accessed atomically
- totalCacheWriteTokens int64 // accumulated cache write tokens, accessed atomically
- subtaskFailed int64 // count of failed subtasks, accessed atomically
- warningsMu sync.Mutex
- warnings []AgentWarning
- compressionMu sync.Mutex
- pendingJob *compressionJob
-}
-
-// 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, err := f()
- if err != nil {
- fmt.Fprintf(stdout.Writer(), "[ocr] CommentWorkerPool error: %v\n", err)
- }
- 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
+ args Args
+ diffs []model.Diff // parsed diffs
+ totalInsertions int64
+ totalDeletions int64
+ currentDate string
+ session *session.SessionHistory
+ subtaskFailed int64 // count of failed subtasks, accessed atomically
+ runner *llmloop.Runner
}
// New creates a new Agent from the given arguments.
@@ -247,10 +170,25 @@ func New(args Args) *Agent {
DiffCommit: args.Commit,
})
}
- return &Agent{
+ a := &Agent{
args: args,
session: args.Session,
}
+ // DiffLookup closure captures a so the runner can resolve per-file
+ // model.Diff records lazily (a.diffs is only populated by loadDiffs,
+ // after New returns).
+ a.runner = llmloop.NewRunner(llmloop.Deps{
+ LLMClient: args.LLMClient,
+ Model: args.Model,
+ Template: args.Template,
+ Tools: args.Tools,
+ MainToolDefs: args.MainToolDefs,
+ CommentCollector: args.CommentCollector,
+ CommentWorkerPool: args.CommentWorkerPool,
+ Session: args.Session,
+ DiffLookup: a.findDiff,
+ })
+ return a
}
// Run executes the full review pipeline: parse diffs -> plan per file -> LLM tool-loop -> collect comments.
@@ -319,48 +257,26 @@ func (a *Agent) Diffs() []model.Diff {
// TotalTokensUsed returns PromptTokens + CompletionTokens across all LLM calls.
// For Anthropic, PromptTokens already includes cache read/write tokens.
-func (a *Agent) TotalTokensUsed() int64 {
- return atomic.LoadInt64(&a.totalInputTokens) + atomic.LoadInt64(&a.totalOutputTokens)
-}
+func (a *Agent) TotalTokensUsed() int64 { return a.runner.TotalTokensUsed() }
// TotalInputTokens returns the accumulated input/prompt tokens from all LLM calls.
-func (a *Agent) TotalInputTokens() int64 {
- return atomic.LoadInt64(&a.totalInputTokens)
-}
+func (a *Agent) TotalInputTokens() int64 { return a.runner.TotalInputTokens() }
// TotalOutputTokens returns the accumulated completion tokens from all LLM calls.
-func (a *Agent) TotalOutputTokens() int64 {
- return atomic.LoadInt64(&a.totalOutputTokens)
-}
+func (a *Agent) TotalOutputTokens() int64 { return a.runner.TotalOutputTokens() }
// TotalCacheReadTokens returns the accumulated cache read tokens from all LLM calls.
-func (a *Agent) TotalCacheReadTokens() int64 {
- return atomic.LoadInt64(&a.totalCacheReadTokens)
-}
+func (a *Agent) TotalCacheReadTokens() int64 { return a.runner.TotalCacheReadTokens() }
// TotalCacheWriteTokens returns the accumulated cache write tokens from all LLM calls.
-func (a *Agent) TotalCacheWriteTokens() int64 {
- return atomic.LoadInt64(&a.totalCacheWriteTokens)
-}
+func (a *Agent) TotalCacheWriteTokens() int64 { return a.runner.TotalCacheWriteTokens() }
// Warnings returns a copy of non-fatal warnings recorded during review.
-func (a *Agent) Warnings() []AgentWarning {
- a.warningsMu.Lock()
- defer a.warningsMu.Unlock()
- out := make([]AgentWarning, len(a.warnings))
- copy(out, a.warnings)
- return out
-}
+func (a *Agent) Warnings() []AgentWarning { return a.runner.Warnings() }
// recordWarning adds a non-fatal warning to the agent's warning list.
func (a *Agent) recordWarning(warningType, file, message string) {
- a.warningsMu.Lock()
- a.warnings = append(a.warnings, AgentWarning{
- File: file,
- Message: message,
- Type: warningType,
- })
- a.warningsMu.Unlock()
+ a.runner.RecordWarning(warningType, file, message)
}
// loadDiffs populates the diff-related fields.
@@ -411,16 +327,6 @@ func (a *Agent) injectDiffMap() {
}
}
-// lookupTool returns the provider for a given tool from the registry,
-// or nil if the tool is not registered.
-func lookupTool(reg *tool.Registry, t tool.Tool) tool.Provider {
- p, ok := reg.Get(t.Name())
- 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) {
startTime := time.Now()
@@ -566,7 +472,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
messages = append(messages, llm.NewTextMessage(m.Role, content))
}
- tokenCount := countMessagesTokens(messages)
+ tokenCount := llmloop.CountMessagesTokens(messages)
maxAllowed := a.args.Template.MaxTokens
tokenLimit := maxAllowed * 4 / 5 // 80% of MaxTokens
if tokenCount > tokenLimit {
@@ -580,8 +486,11 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
return nil
}
- err := a.performLlmCodeReview(ctx, messages, newPath)
+ err := a.runner.RunPerFile(ctx, messages, newPath)
if err == nil {
+ // REVIEW_FILTER_TASK runs after the main loop and decides which of the
+ // just-collected comments to drop. It needs to see comments produced by
+ // the async CommentWorkerPool, so wait for that to drain first.
if a.args.CommentWorkerPool != nil {
a.args.CommentWorkerPool.Await()
}
@@ -635,12 +544,7 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s
return
}
rec.SetResponse(resp, time.Since(startTime))
- if resp.Usage != nil {
- atomic.AddInt64(&a.totalInputTokens, resp.Usage.PromptTokens)
- atomic.AddInt64(&a.totalOutputTokens, resp.Usage.CompletionTokens)
- atomic.AddInt64(&a.totalCacheReadTokens, resp.Usage.CacheReadTokens)
- atomic.AddInt64(&a.totalCacheWriteTokens, resp.Usage.CacheWriteTokens)
- }
+ a.runner.RecordUsage(resp.Usage)
indices := parseFilterResponse(resp.Content(), len(comments))
if len(indices) == 0 {
@@ -673,7 +577,7 @@ func buildFilterCommentsJSON(comments []model.LlmComment) string {
// parseFilterResponse extracts comment indices from the LLM filter response.
// Returns a set of 0-based indices. Invalid IDs or out-of-range indices are ignored.
func parseFilterResponse(raw string, total int) map[int]struct{} {
- raw = stripMarkdownFences(raw)
+ raw = llmloop.StripMarkdownFences(raw)
var ids []string
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
preview := raw
@@ -845,12 +749,7 @@ func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFi
return "", fmt.Errorf("plan request: %w", err)
}
rec.SetResponse(resp, time.Since(startTime))
- if resp.Usage != nil {
- atomic.AddInt64(&a.totalInputTokens, resp.Usage.PromptTokens)
- atomic.AddInt64(&a.totalOutputTokens, resp.Usage.CompletionTokens)
- atomic.AddInt64(&a.totalCacheReadTokens, resp.Usage.CacheReadTokens)
- atomic.AddInt64(&a.totalCacheWriteTokens, resp.Usage.CacheWriteTokens)
- }
+ a.runner.RecordUsage(resp.Usage)
fmt.Fprintf(stdout.Writer(), "[ocr] Plan completed for %s\n", newPath)
return resp.Content(), nil
}
@@ -893,236 +792,6 @@ func formatToolDefs(toolDefs []llm.ToolDef) string {
return sb.String()
}
-// performLlmCodeReview drives the main LLM conversation loop for a single file.
-// It sends messages with tool definitions, handles tool calls returned by the model,
-// and collects review comments until task_done is called or limits are reached.
-func (a *Agent) performLlmCodeReview(ctx context.Context, messages []llm.Message, newPath string) error {
- toolReqCount := a.args.Template.MaxToolRequestTimes
- const maxConsecutiveEmptyRounds = 3
- consecutiveEmptyRounds := 0
-
- for toolReqCount > 0 {
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
-
- toolReqCount--
-
- fs := a.session.GetOrCreateFileSession(newPath)
- rec := fs.AppendTaskRecord(session.MainTask, append([]llm.Message(nil), messages...))
- startTime := time.Now()
-
- resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
- Model: a.args.Model,
- Messages: messages,
- Tools: a.args.MainToolDefs,
- MaxTokens: a.args.Template.MaxTokens,
- })
- duration := time.Since(startTime)
- if err != nil {
- rec.SetError(err, duration)
- telemetry.RecordLLMRequest(ctx, a.args.Model, duration, 0, "error")
- return fmt.Errorf("LLM completion error: %w", err)
- }
- rec.SetResponse(resp, duration)
- // Record LLM metrics with token info from API response usage field.
- totalTokens := int64(0)
- if resp.Usage != nil {
- totalTokens = resp.Usage.TotalTokens
- atomic.AddInt64(&a.totalInputTokens, resp.Usage.PromptTokens)
- atomic.AddInt64(&a.totalOutputTokens, resp.Usage.CompletionTokens)
- atomic.AddInt64(&a.totalCacheReadTokens, resp.Usage.CacheReadTokens)
- atomic.AddInt64(&a.totalCacheWriteTokens, resp.Usage.CacheWriteTokens)
- }
- telemetry.RecordLLMRequest(ctx, a.args.Model, duration, totalTokens, "ok")
-
- content := resp.Content()
- calls := resp.ToolCalls()
-
- if len(calls) == 0 {
- // No tool calls - remind the model
- fmt.Fprintf(stdout.Writer(), "[ocr] No tool calls parsed for %s, retrying...\n", newPath)
- messages = append(messages, llm.NewTextMessage("user", "You did not successfully call any tools. Please try again or use task_done if finished."))
- if content != "" {
- messages = append(messages[:len(messages)-1], llm.NewTextMessage("assistant", content), messages[len(messages)-1])
- }
- continue
- }
-
- var results []tool.ToolCallResult
- taskCompleted := false
- hasValidResult := false
-
- for _, call := range calls {
- cp := a.executeToolCall(ctx, newPath, call, rec)
- 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 {
- consecutiveEmptyRounds++
- if consecutiveEmptyRounds >= maxConsecutiveEmptyRounds {
- fmt.Fprintf(stdout.Writer(), "[ocr] Too many empty retries for %s, stopping.\n", newPath)
- break
- }
- fmt.Fprintf(stdout.Writer(), "[ocr] No valid tool results for %s, retrying...\n", newPath)
- } else {
- consecutiveEmptyRounds = 0
- }
-
- succeed := a.addNextMessage(ctx, content, calls, results, &messages, newPath)
- if !succeed {
- fmt.Fprintf(stdout.Writer(), "[ocr] Context compression exceeded threshold for %s, stopping.\n", newPath)
- break
- }
- }
-
- if toolReqCount <= 0 {
- fmt.Fprintf(stdout.Writer(), "[ocr] Max tool requests reached for %s.\n", newPath)
- }
-
- return nil
-}
-
-// executeToolCall executes a single tool call from the LLM response and records
-// the result in session history. It handles async dispatch for code_comment when
-// a worker pool is configured, otherwise runs synchronously.
-func (a *Agent) executeToolCall(ctx context.Context, newPath string, call llm.ToolCall, rec *session.TaskRecord) tool.TaskCheckpoint {
- t := tool.OfName(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.Name(), err))
- }
-
- // Inject current file path as default for code_comment when not provided.
- // The model already knows which file it's reviewing, so it omits path.
- if t == tool.CodeComment && newPath != "" {
- if _, ok := args["path"]; !ok {
- args["path"] = newPath
- }
- }
-
- startTime := time.Now()
-
- // code_comment: parse → resolve line numbers → re-locate if needed → add to collector
- if t == tool.CodeComment {
- telemetry.PrintToolCallStarted(t.Name(), args)
-
- comments, errMsg := tool.ParseComments(args)
- if errMsg != "" {
- telemetry.RecordToolCall(ctx, t.Name(), time.Since(startTime), false)
- return tool.Of(errMsg)
- }
-
- resolveAndCollect := func(rctx context.Context) {
- for i := range comments {
- cm := &comments[i]
- d := a.findDiff(cm.Path)
- if d != nil {
- if !diff.ResolveComment(cm, d) && a.args.Template.ReLocationTask != nil {
- rlStart := time.Now()
- _, resp, msgs := diff.ReLocateComment(rctx, cm, d, a.args.LLMClient, a.args.Template.ReLocationTask, a.args.Model, a.args.Template.MaxTokens)
- if msgs != nil {
- fs := a.session.GetOrCreateFileSession(cm.Path)
- rlRec := fs.AppendTaskRecord(session.ReLocationTask, msgs)
- if resp != nil {
- rlRec.SetResponse(resp, time.Since(rlStart))
- if resp.Usage != nil {
- atomic.AddInt64(&a.totalInputTokens, resp.Usage.PromptTokens)
- atomic.AddInt64(&a.totalOutputTokens, resp.Usage.CompletionTokens)
- atomic.AddInt64(&a.totalCacheReadTokens, resp.Usage.CacheReadTokens)
- atomic.AddInt64(&a.totalCacheWriteTokens, resp.Usage.CacheWriteTokens)
- }
- } else {
- rlRec.SetError(fmt.Errorf("re-location LLM call failed"), time.Since(rlStart))
- }
- }
- }
- }
- a.args.CommentCollector.Add(*cm)
- }
- }
-
- if a.args.CommentWorkerPool != nil {
- if rec != nil {
- rec.AddToolResult(t.Name(), call.Function.Arguments, "(async)")
- }
- pool := a.args.CommentWorkerPool
- asyncCtx := context.WithoutCancel(ctx)
- toolName := t.Name()
- pool.Submit(func() ([]model.LlmComment, error) {
- resolveAndCollect(asyncCtx)
- telemetry.PrintToolCallFinished(toolName, time.Since(startTime))
- return []model.LlmComment{}, nil
- })
- telemetry.RecordToolCall(asyncCtx, toolName, time.Since(startTime), true)
- return tool.Of(tool.CommentSucceed)
- }
-
- resolveAndCollect(ctx)
- dur := time.Since(startTime)
- telemetry.RecordToolCall(ctx, t.Name(), dur, true)
- telemetry.PrintToolCallFinished(t.Name(), dur)
- if rec != nil {
- rec.AddToolResult(t.Name(), call.Function.Arguments, tool.CommentSucceed)
- }
- return tool.Of(tool.CommentSucceed)
- }
-
- // Synchronous path for all other tools
- telemetry.PrintToolCallStarted(t.Name(), args)
- result, err := p.Execute(ctx, args)
- dur := time.Since(startTime)
- ok := err == nil
- telemetry.RecordToolCall(ctx, t.Name(), dur, ok)
-
- if err != nil {
- telemetry.PrintToolCallError(t.Name(), err)
- return tool.Of(fmt.Sprintf("Error executing tool %s: %v", t.Name(), err))
- }
- telemetry.PrintToolCallFinished(t.Name(), dur)
- if rec != nil {
- rec.AddToolResult(t.Name(), call.Function.Arguments, result)
- }
- return tool.Of(result)
-}
// findDiff returns the Diff for the given file path, or nil if not found.
func (a *Agent) findDiff(path string) *model.Diff {
@@ -1134,301 +803,6 @@ func (a *Agent) findDiff(path string) *model.Diff {
return nil
}
-// collectPendingComments waits for any async workers then returns aggregated comments from the collector.
-func (a *Agent) collectPendingComments() []model.LlmComment {
- if a.args.CommentWorkerPool != nil {
- a.args.CommentWorkerPool.Await()
- }
- return a.args.CommentCollector.Comments()
-}
-
-// addNextMessage adds assistant + tool response messages to the conversation history.
-// Implements dual-threshold compression:
-// - 60% of MaxTokens: trigger async background compression (non-blocking)
-// - 80% of MaxTokens: perform synchronous compression immediately
-func (a *Agent) addNextMessage(ctx context.Context, assistantContent string, toolCalls []llm.ToolCall, results []tool.ToolCallResult, messages *[]llm.Message, filePath string) bool {
- maxAllowed := a.args.Template.MaxTokens
- softLimit := int(float64(maxAllowed) * tokenSoftThreshold)
- warnLimit := int(float64(maxAllowed) * tokenWarningThreshold)
-
- // Try to apply any completed async compression from a previous iteration.
- a.tryApplyPendingCompression(messages)
-
- tokenCount := countMessagesTokens(*messages)
-
- // Hard threshold: synchronous compression.
- if tokenCount > warnLimit {
- a.cancelPendingCompression()
- *messages, _ = a.runCompression(ctx, *messages, filePath)
- tokenCount = countMessagesTokens(*messages)
- }
-
- // Soft threshold: async compression.
- if tokenCount > softLimit && a.pendingJob == nil {
- a.triggerAsyncCompression(ctx, *messages, filePath)
- }
-
- // Add assistant message with tool_calls when present.
- if len(toolCalls) > 0 {
- *messages = append(*messages, llm.NewToolCallMessage(assistantContent, toolCalls))
- } else if assistantContent != "" {
- *messages = append(*messages, llm.NewTextMessage("assistant", assistantContent))
- }
-
- // Add tool response messages using Claude's tool_result format.
- for _, r := range results {
- *messages = append(*messages, llm.NewToolResultMessage(r.ToolCallID, r.Result))
- }
-
- // Final check: compress synchronously if still over warning limit.
- finalCount := countMessagesTokens(*messages)
- if finalCount > warnLimit {
- a.cancelPendingCompression()
- *messages, _ = a.runCompression(ctx, *messages, filePath)
- }
-
- return countMessagesTokens(*messages) < warnLimit
-}
-
-func countMessagesTokens(msgs []llm.Message) int {
- var total int
- for _, m := range msgs {
- total += llm.CountTokens(m.ExtractText())
- }
- return total
-}
-
-// groupIntoRounds parses messages[start:] into logical (assistant + tool_results) pairs.
-func groupIntoRounds(messages []llm.Message, start int) []round {
- var rounds []round
- i := start
- for i < len(messages) {
- if messages[i].Role == "assistant" {
- r := round{assistantIdx: i}
- i++
- for i < len(messages) && messages[i].Role == "tool" {
- r.toolIdxs = append(r.toolIdxs, i)
- i++
- }
- rounds = append(rounds, r)
- } else {
- i++
- }
- }
- return rounds
-}
-
-// computeActiveZoneSize returns how many trailing rounds fit within the remaining
-// token budget after accounting for frozen zone and the compressed summary.
-func computeActiveZoneSize(rounds []round, messages []llm.Message, maxTokens int, reservedTokens int) int {
- budget := int(float64(maxTokens)*tokenWarningThreshold) - reservedTokens
- if budget <= 0 {
- return 0
- }
-
- count := 0
- tokensUsed := 0
- for i := len(rounds) - 1; i >= 0; i-- {
- roundTokens := llm.CountTokens(messages[rounds[i].assistantIdx].ExtractText())
- for _, ti := range rounds[i].toolIdxs {
- roundTokens += llm.CountTokens(messages[ti].ExtractText())
- }
- if tokensUsed+roundTokens > budget {
- break
- }
- tokensUsed += roundTokens
- count++
- }
- return count
-}
-
-// partitionMessages divides messages into frozen, compress, and active zones.
-// Frozen zone is always messages[0:2]. Active zone preserves the K most recent
-// complete rounds based on available token budget.
-func partitionMessages(messages []llm.Message, maxTokens int, prevSummaryTokenEstimate int) partitionResult {
- result := partitionResult{frozenEnd: 2}
- if len(messages) <= 2 {
- result.compressEnd = len(messages)
- return result
- }
-
- result.rounds = groupIntoRounds(messages, 2)
- if len(result.rounds) == 0 {
- result.compressEnd = len(messages)
- return result
- }
-
- result.activeCount = computeActiveZoneSize(result.rounds, messages, maxTokens, prevSummaryTokenEstimate)
- if result.activeCount >= len(result.rounds) {
- // Everything fits — no compression needed.
- result.compressEnd = len(messages)
- result.activeCount = 0
- return result
- }
-
- // compressEnd = index after the last round NOT in active zone.
- activeStartIdx := len(result.rounds) - result.activeCount
- lastCompressRound := result.rounds[activeStartIdx-1]
- if len(lastCompressRound.toolIdxs) > 0 {
- result.compressEnd = lastCompressRound.toolIdxs[len(lastCompressRound.toolIdxs)-1] + 1
- } else {
- result.compressEnd = lastCompressRound.assistantIdx + 1
- }
-
- return result
-}
-
-// stripMarkdownFences removes ```json and ``` wrappers that some models
-// add around structured outputs.
-func stripMarkdownFences(s string) string {
- s = strings.TrimSpace(s)
- if strings.HasPrefix(s, "```") {
- if nl := strings.IndexByte(s, '\n'); nl >= 0 {
- s = s[nl+1:]
- } else {
- s = strings.TrimPrefix(s, "```json")
- s = strings.TrimPrefix(s, "```")
- }
- }
- s = strings.TrimSpace(s)
- if strings.HasSuffix(s, "```") {
- s = strings.TrimSuffix(s, "```")
- s = strings.TrimSpace(s)
- }
- return s
-}
-
-// runCompression performs three-zone memory compression on the given messages.
-// It summarizes the compress zone while preserving the active zone intact.
-// Returns the rebuilt messages slice: [frozen] + [compressed_summary] + [active].
-func (a *Agent) runCompression(ctx context.Context, msgs []llm.Message, filePath string) ([]llm.Message, error) {
- if len(a.args.Template.MemoryCompressionTask.Messages) == 0 || len(msgs) <= 2 {
- return msgs[:min(len(msgs), 2)], nil
- }
-
- part := partitionMessages(msgs, a.args.Template.MaxTokens, 0)
- if part.compressEnd <= part.frozenEnd {
- return msgs, nil
- }
-
- contextXML := buildMessageXML(msgs[part.frozenEnd:part.compressEnd])
-
- compressionMsgs := make([]llm.Message, 0, len(a.args.Template.MemoryCompressionTask.Messages))
- for _, m := range a.args.Template.MemoryCompressionTask.Messages {
- content := strings.ReplaceAll(m.Content, "{{context}}", contextXML)
- compressionMsgs = append(compressionMsgs, llm.NewTextMessage(m.Role, content))
- }
-
- startTime := time.Now()
- resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
- Model: a.args.Model,
- Messages: compressionMsgs,
- MaxTokens: a.args.Template.MaxTokens,
- })
- duration := time.Since(startTime)
-
- fs := a.session.GetOrCreateFileSession(filePath)
- rec := fs.AppendTaskRecord(session.MemoryCompressionTask, compressionMsgs)
- if err != nil {
- rec.SetError(err, duration)
- fmt.Fprintf(stdout.Writer(), "[ocr] Memory compression failed: %v\n", err)
- return msgs[:part.frozenEnd], fmt.Errorf("memory compression: %w", err)
- }
- rec.SetResponse(resp, duration)
- if resp.Usage != nil {
- atomic.AddInt64(&a.totalInputTokens, resp.Usage.PromptTokens)
- atomic.AddInt64(&a.totalOutputTokens, resp.Usage.CompletionTokens)
- atomic.AddInt64(&a.totalCacheReadTokens, resp.Usage.CacheReadTokens)
- atomic.AddInt64(&a.totalCacheWriteTokens, resp.Usage.CacheWriteTokens)
- }
-
- rawSummary := stripMarkdownFences(resp.Content())
- if rawSummary == "" {
- return msgs[:part.frozenEnd], nil
- }
-
- rebuilt := make([]llm.Message, 2)
- copy(rebuilt, msgs[:2])
-
- userMsg := rebuilt[1]
- currentText := userMsg.ExtractText()
- rebuilt[1] = llm.NewTextMessage(userMsg.Role, currentText+"\n\n\n"+rawSummary+"\n")
-
- for i := part.compressEnd; i < len(msgs); i++ {
- rebuilt = append(rebuilt, msgs[i])
- }
-
- return rebuilt, nil
-}
-
-// triggerAsyncCompression kicks off a background compression job.
-func (a *Agent) triggerAsyncCompression(ctx context.Context, messages []llm.Message, filePath string) {
- msgSnapshot := copyMessages(messages)
-
- asyncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
-
- job := &compressionJob{done: make(chan struct{}), cancel: cancel}
- a.compressionMu.Lock()
- a.pendingJob = job
- a.compressionMu.Unlock()
-
- go func() {
- defer cancel()
- rebuilt, _ := a.runCompression(asyncCtx, msgSnapshot, filePath)
-
- a.compressionMu.Lock()
- defer a.compressionMu.Unlock()
-
- if a.pendingJob != job {
- return // cancelled or superseded
- }
- job.rebuilt = rebuilt
- close(job.done)
- }()
-}
-
-// tryApplyPendingCompression checks if a background compression completed
-// and swaps the rebuilt messages into place. Returns true if applied.
-func (a *Agent) tryApplyPendingCompression(messages *[]llm.Message) bool {
- a.compressionMu.Lock()
- job := a.pendingJob
- a.compressionMu.Unlock()
-
- if job == nil {
- return false
- }
-
- select {
- case <-job.done:
- a.compressionMu.Lock()
- if a.pendingJob == job && job.rebuilt != nil {
- *messages = job.rebuilt
- a.pendingJob = nil
- }
- a.compressionMu.Unlock()
- return true
- default:
- return false
- }
-}
-
-// cancelPendingCompression aborts any in-flight background compression.
-func (a *Agent) cancelPendingCompression() {
- a.compressionMu.Lock()
- defer a.compressionMu.Unlock()
-
- if a.pendingJob != nil {
- a.pendingJob.cancel()
- a.pendingJob = nil
- }
-}
-
-// copyMessages creates a shallow copy of a message slice.
-func copyMessages(msgs []llm.Message) []llm.Message {
- out := make([]llm.Message, len(msgs))
- copy(out, msgs)
- return out
-}
// BuildToolDefs converts toolsconfig.ToolConfigEntry slice into []llm.ToolDef,
// filtering by phase (planOnly=true for plan_task, false for main_task).
@@ -1452,20 +826,6 @@ func BuildToolDefs(entries []toolsconfig.ToolConfigEntry, planOnly bool) []llm.T
return defs
}
-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.ExtractText()))
- sb.WriteString(" \n")
- sb.WriteString("")
- if i < len(msgs)-1 {
- sb.WriteString("\n")
- }
- }
- return sb.String()
-}
func reviewModeString(from, to, commit string) string {
if commit != "" {
diff --git a/internal/agent/preview.go b/internal/agent/preview.go
index 0d1003f..28282d0 100644
--- a/internal/agent/preview.go
+++ b/internal/agent/preview.go
@@ -8,38 +8,24 @@ import (
"github.com/open-code-review/open-code-review/internal/model"
)
-// ExcludeReason describes why a file was excluded from review.
-type ExcludeReason string
-
+// ExcludeReason / DiffPreview / DiffPreviewEntry are now type aliases of
+// the mode-agnostic preview types in internal/model. Kept for backwards
+// compatibility with existing call sites; internal/scan returns the same
+// model.Preview shape directly.
+type ExcludeReason = model.ExcludeReason
+type DiffPreview = model.Preview
+type DiffPreviewEntry = model.PreviewEntry
+
+// Re-export the constants so callers can keep writing agent.ExcludeBinary.
const (
- ExcludeNone ExcludeReason = ""
- ExcludeUserRule ExcludeReason = "user_exclude"
- ExcludeExtension ExcludeReason = "unsupported_ext"
- ExcludeDefaultPath ExcludeReason = "default_path"
- ExcludeDeleted ExcludeReason = "deleted"
- ExcludeBinary ExcludeReason = "binary"
+ ExcludeNone = model.ExcludeNone
+ ExcludeUserRule = model.ExcludeUserRule
+ ExcludeExtension = model.ExcludeExtension
+ ExcludeDefaultPath = model.ExcludeDefaultPath
+ ExcludeDeleted = model.ExcludeDeleted
+ ExcludeBinary = model.ExcludeBinary
)
-// DiffPreviewEntry is one file's preview record.
-type DiffPreviewEntry struct {
- Path string `json:"path"`
- Status string `json:"status"`
- Insertions int64 `json:"insertions"`
- Deletions int64 `json:"deletions"`
- WillReview bool `json:"will_review"`
- ExcludeReason ExcludeReason `json:"exclude_reason,omitempty"`
-}
-
-// DiffPreview is the full preview result.
-type DiffPreview struct {
- Entries []DiffPreviewEntry `json:"files"`
- TotalInsertions int64 `json:"total_insertions"`
- TotalDeletions int64 `json:"total_deletions"`
- TotalFiles int `json:"total_files"`
- ReviewableCount int `json:"reviewable_count"`
- ExcludedCount int `json:"excluded_count"`
-}
-
// whyExcluded applies the filter algorithm as shouldReview but
// returns the specific reason a file is excluded.
func (a *Agent) whyExcluded(d model.Diff) ExcludeReason {
diff --git a/internal/config/template/task_template.json b/internal/config/template/task_template.json
index b3077a1..4c5b407 100644
--- a/internal/config/template/task_template.json
+++ b/internal/config/template/task_template.json
@@ -53,9 +53,23 @@
},
"TOOL_REQUEST_WAIT_TIME_MS": 10000,
"MAX_TOOL_REQUEST_TIMES": 30,
+ "FULL_SCAN_MAX_TOOL_REQUEST_TIMES": 60,
"MAX_SUBTASK_EXECUTION_TIME_MINUTES": 5,
"PLAN_MODE_LINE_THRESHOLD": 50,
"MAX_TOKENS": 58888,
+ "FULL_SCAN_TASK": {
+ "messages": [
+ {
+ "role": "system",
+ "content": "## Role\nYou are a code review assistant developed by Alibaba. Unlike diff-based review, in this task you are reviewing an ENTIRE existing source file (no diff context). Provide professional review feedback identifying real defects, bugs, security risks, performance issues, concurrency hazards, and maintainability problems in the file as it stands today.\n\n## Capabilities\n- Think step by step.\n- Read the full file content carefully before commenting.\n- Be objective and grounded in the source. When context is insufficient, call context tools instead of guessing.\n- Focus on substantive correctness/security/performance/maintainability issues. Avoid stylistic nitpicks unless the project's review rules call them out.\n- Do not comment on code in other files — context tools are reference only; findings outside the current file MUST NOT become the subject of your comments.\n- Avoid commenting on auto-generated code, code comments, or metadata annotations unless the user's review rules require it.\n\n## Tool-call discipline (IMPORTANT — your tool-call budget per file is limited)\n- The file content is ALREADY in ; do NOT call `file_read` to re-fetch the current file.\n- Limit context-gathering to AT MOST 2-3 tool calls per finding. Avoid calling the same tool with the same arguments more than once.\n- Once you have enough evidence for an issue, call `code_comment` immediately rather than gathering more context.\n- Batch multiple comments in a single `code_comment` call (its `comments` parameter accepts an array) instead of issuing one call per finding.\n- If no further issues are evident after a quick sweep of the file, call `task_done` immediately — do not keep probing for marginal findings.\n\n## Reply limit\n- If the review is complete, call `task_done` to end the task.\n- For each confirmed issue, call `code_comment` to record feedback (prefer batching).\n- If additional context is genuinely needed, call the appropriate context tool, but stay within the budget above."
+ },
+ {
+ "role": "user",
+ "content": "{{current_file_path}}\n\n\n{{file_content}}\n\n\nCurrent time in the real world: {{current_system_date_time}}\n\n\n### Requirement Background (Optional)\n{{requirement_background}}\n\n### Review Checklist\n{{system_rule}}\n\nReview the entire source file in and report all findings via code_comment, then call task_done.\n"
+ }
+ ],
+ "timeout": 180
+ },
"RE_LOCATION_TASK": {
"messages": [
{
diff --git a/internal/config/template/template.go b/internal/config/template/template.go
index 738b12f..ab22a1c 100644
--- a/internal/config/template/template.go
+++ b/internal/config/template/template.go
@@ -8,8 +8,13 @@ import (
)
// Template holds the native agent task template configuration.
-// Mirrors NativeAgentTemplate from the Java implementation, loaded via JSON at runtime.
+// Mirrors NativeAgentTemplate from the Java implementation, loaded via JSON
+// at runtime.
+//
+// Field grouping: the "scan-only fields" section is consumed exclusively by
+// internal/scan; internal/agent (diff review) does not read those fields.
type Template struct {
+ // Diff-review fields.
MainTask LlmConversation `json:"MAIN_TASK"`
PlanTask *LlmConversation `json:"PLAN_TASK,omitempty"`
MemoryCompressionTask LlmConversation `json:"MEMORY_COMPRESSION_TASK"`
@@ -20,6 +25,10 @@ type Template struct {
PlanModeLineThreshold int `json:"PLAN_MODE_LINE_THRESHOLD"`
ReLocationTask *LlmConversation `json:"RE_LOCATION_TASK,omitempty"`
ReviewFilterTask *LlmConversation `json:"REVIEW_FILTER_TASK,omitempty"`
+
+ // Scan-only fields — consumed by internal/scan, never by internal/agent.
+ FullScanTask *LlmConversation `json:"FULL_SCAN_TASK,omitempty"`
+ FullScanMaxToolRequestTimes int `json:"FULL_SCAN_MAX_TOOL_REQUEST_TIMES,omitempty"`
}
//go:embed task_template.json
@@ -52,13 +61,17 @@ func resolveLang(lang string) string {
}
// ApplyLanguage injects a language directive into all system-role messages
-// across MAIN_TASK, PLAN_TASK (if set), and MEMORY_COMPRESSION_TASK.
+// across MAIN_TASK, PLAN_TASK (if set), FULL_SCAN_TASK (if set), and
+// MEMORY_COMPRESSION_TASK.
func (t *Template) ApplyLanguage(lang string) {
instruction := "\n\nAlways respond in " + resolveLang(lang) + "."
applyLanguage(&t.MainTask, instruction)
if t.PlanTask != nil {
applyLanguage(t.PlanTask, instruction)
}
+ if t.FullScanTask != nil {
+ applyLanguage(t.FullScanTask, instruction)
+ }
applyLanguage(&t.MemoryCompressionTask, instruction)
}
func (t *Template) Validate() error {
diff --git a/internal/config/template/template_test.go b/internal/config/template/template_test.go
new file mode 100644
index 0000000..5512c44
--- /dev/null
+++ b/internal/config/template/template_test.go
@@ -0,0 +1,37 @@
+package template
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestLoadDefault_FullScanBudgetParsed(t *testing.T) {
+ tpl, err := LoadDefault()
+ if err != nil {
+ t.Fatalf("LoadDefault: %v", err)
+ }
+ if tpl.FullScanMaxToolRequestTimes <= tpl.MaxToolRequestTimes {
+ t.Errorf("FullScanMaxToolRequestTimes(%d) must exceed MaxToolRequestTimes(%d)",
+ tpl.FullScanMaxToolRequestTimes, tpl.MaxToolRequestTimes)
+ }
+ if tpl.FullScanTask == nil || len(tpl.FullScanTask.Messages) == 0 {
+ t.Fatal("FullScanTask must be populated from the embedded template")
+ }
+}
+
+func TestApplyLanguage_AppliesToFullScanTask(t *testing.T) {
+ tpl, err := LoadDefault()
+ if err != nil {
+ t.Fatalf("LoadDefault: %v", err)
+ }
+ tpl.ApplyLanguage("Spanish")
+
+ for _, m := range tpl.FullScanTask.Messages {
+ if m.Role != "system" {
+ continue
+ }
+ if !strings.Contains(m.Content, "Always respond in Spanish.") {
+ t.Errorf("language directive missing from FullScanTask system message")
+ }
+ }
+}
\ No newline at end of file
diff --git a/internal/diff/git.go b/internal/diff/git.go
index 765516b..8eb5bbd 100644
--- a/internal/diff/git.go
+++ b/internal/diff/git.go
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "slices"
"strings"
"github.com/open-code-review/open-code-review/internal/gitcmd"
@@ -167,7 +168,7 @@ func (p *Provider) loadGitignorePatterns() []string {
return nil
}
var patterns []string
- for _, line := range strings.Split(string(data), "\n") {
+ for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
@@ -200,16 +201,11 @@ func (p *Provider) isPathExcluded(relPath string, gitignorePatterns []string) bo
// matchGitignorePattern checks if relPath matches a single .gitignore pattern.
func matchGitignorePattern(relPath, pat string) bool {
// Directory-only patterns (trailing /)
- if strings.HasSuffix(pat, "/") {
- dirName := strings.TrimSuffix(pat, "/")
+ if before, ok := strings.CutSuffix(pat, "/"); ok {
+ dirName := before
// Match if any path segment equals the dir name
segments := strings.Split(relPath, "/")
- for _, seg := range segments {
- if seg == dirName {
- return true
- }
- }
- return false
+ return slices.Contains(segments, dirName)
}
// Negation patterns are not needed for exclusion purposes
diff --git a/internal/diff/gitignore.go b/internal/diff/gitignore.go
new file mode 100644
index 0000000..175195a
--- /dev/null
+++ b/internal/diff/gitignore.go
@@ -0,0 +1,33 @@
+package diff
+
+// ExcludedDirs is the list of directory prefixes that scanners and diff
+// providers should always skip. Exposed so internal/scan and other consumers
+// can reuse the same blocklist.
+func ExcludedDirs() []string {
+ out := make([]string, len(providerDirIgnoreDirs))
+ copy(out, providerDirIgnoreDirs)
+ return out
+}
+
+// LoadGitignorePatterns reads and parses .gitignore patterns from the given
+// repository root. Returns nil if the file is missing or unreadable.
+func LoadGitignorePatterns(repoDir string) []string {
+ stub := &Provider{repoDir: repoDir}
+ return stub.loadGitignorePatterns()
+}
+
+// IsPathExcluded returns true when relPath matches any of the supplied
+// gitignore-style patterns or any default excluded directory prefix
+// (see ExcludedDirs).
+func IsPathExcluded(repoDir, relPath string, patterns []string) bool {
+ stub := &Provider{repoDir: repoDir}
+ return stub.isPathExcluded(relPath, patterns)
+}
+
+// MatchGitignorePattern reports whether relPath matches a single
+// gitignore-style pattern, using the simplified semantics that diff.Provider
+// already implements (basename match, prefix match, directory-only suffix).
+// Useful when callers want to test a single pattern in isolation.
+func MatchGitignorePattern(relPath, pat string) bool {
+ return matchGitignorePattern(relPath, pat)
+}
diff --git a/internal/llmloop/compression.go b/internal/llmloop/compression.go
new file mode 100644
index 0000000..1dfc763
--- /dev/null
+++ b/internal/llmloop/compression.go
@@ -0,0 +1,310 @@
+package llmloop
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/open-code-review/open-code-review/internal/llm"
+ "github.com/open-code-review/open-code-review/internal/session"
+ "github.com/open-code-review/open-code-review/internal/stdout"
+)
+
+// Compression thresholds, as fractions of MaxTokens.
+const (
+ tokenSoftThreshold = 0.60 // async background compression
+ tokenWarningThreshold = 0.80 // immediate sync compression
+)
+
+// round groups consecutive messages starting with an assistant message
+// followed by zero or more tool result messages.
+type round struct {
+ assistantIdx int
+ toolIdxs []int
+}
+
+// partitionResult describes how messages should be split for compression.
+type partitionResult struct {
+ frozenEnd int
+ compressEnd int
+ rounds []round
+ activeCount int
+}
+
+// compressionJob tracks an in-flight background compression operation.
+type compressionJob struct {
+ done chan struct{}
+ rebuilt []llm.Message
+ cancel context.CancelFunc
+}
+
+// CountMessagesTokens returns the rough token count of msgs by summing the
+// per-message text token count. Exported because both review and scan top
+// layers may want it for pre-flight checks.
+func CountMessagesTokens(msgs []llm.Message) int {
+ var total int
+ for _, m := range msgs {
+ total += llm.CountTokens(m.ExtractText())
+ }
+ return total
+}
+
+// groupIntoRounds parses messages[start:] into logical
+// (assistant + tool_results) pairs.
+func groupIntoRounds(messages []llm.Message, start int) []round {
+ var rounds []round
+ i := start
+ for i < len(messages) {
+ if messages[i].Role == "assistant" {
+ r := round{assistantIdx: i}
+ i++
+ for i < len(messages) && messages[i].Role == "tool" {
+ r.toolIdxs = append(r.toolIdxs, i)
+ i++
+ }
+ rounds = append(rounds, r)
+ } else {
+ i++
+ }
+ }
+ return rounds
+}
+
+// computeActiveZoneSize returns how many trailing rounds fit within the
+// remaining token budget after accounting for the frozen zone and the
+// compressed summary.
+func computeActiveZoneSize(rounds []round, messages []llm.Message, maxTokens int, reservedTokens int) int {
+ budget := int(float64(maxTokens)*tokenWarningThreshold) - reservedTokens
+ if budget <= 0 {
+ return 0
+ }
+
+ count := 0
+ tokensUsed := 0
+ for i := len(rounds) - 1; i >= 0; i-- {
+ roundTokens := llm.CountTokens(messages[rounds[i].assistantIdx].ExtractText())
+ for _, ti := range rounds[i].toolIdxs {
+ roundTokens += llm.CountTokens(messages[ti].ExtractText())
+ }
+ if tokensUsed+roundTokens > budget {
+ break
+ }
+ tokensUsed += roundTokens
+ count++
+ }
+ return count
+}
+
+// partitionMessages divides messages into frozen, compress, and active zones.
+// Frozen zone is always messages[0:2]. Active zone preserves the K most
+// recent complete rounds based on available token budget.
+func partitionMessages(messages []llm.Message, maxTokens int, prevSummaryTokenEstimate int) partitionResult {
+ result := partitionResult{frozenEnd: 2}
+ if len(messages) <= 2 {
+ result.compressEnd = len(messages)
+ return result
+ }
+
+ result.rounds = groupIntoRounds(messages, 2)
+ if len(result.rounds) == 0 {
+ result.compressEnd = len(messages)
+ return result
+ }
+
+ result.activeCount = computeActiveZoneSize(result.rounds, messages, maxTokens, prevSummaryTokenEstimate)
+ if result.activeCount >= len(result.rounds) {
+ // Everything fits — no compression needed.
+ result.compressEnd = len(messages)
+ result.activeCount = 0
+ return result
+ }
+
+ // compressEnd = index after the last round NOT in active zone.
+ activeStartIdx := len(result.rounds) - result.activeCount
+ lastCompressRound := result.rounds[activeStartIdx-1]
+ if len(lastCompressRound.toolIdxs) > 0 {
+ result.compressEnd = lastCompressRound.toolIdxs[len(lastCompressRound.toolIdxs)-1] + 1
+ } else {
+ result.compressEnd = lastCompressRound.assistantIdx + 1
+ }
+
+ return result
+}
+
+// StripMarkdownFences removes ```json and ``` wrappers some models add
+// around structured outputs. Exposed so callers (e.g. agent's review-filter
+// post-step) that parse LLM JSON output can reuse the same heuristic.
+func StripMarkdownFences(s string) string { return stripMarkdownFences(s) }
+
+// stripMarkdownFences is the package-private workhorse used by the
+// internal compression code paths.
+func stripMarkdownFences(s string) string {
+ s = strings.TrimSpace(s)
+ if strings.HasPrefix(s, "```") {
+ if nl := strings.IndexByte(s, '\n'); nl >= 0 {
+ s = s[nl+1:]
+ } else {
+ s = strings.TrimPrefix(s, "```json")
+ s = strings.TrimPrefix(s, "```")
+ }
+ }
+ s = strings.TrimSpace(s)
+ if strings.HasSuffix(s, "```") {
+ s = strings.TrimSuffix(s, "```")
+ s = strings.TrimSpace(s)
+ }
+ return s
+}
+
+// buildMessageXML serializes msgs into the form expected
+// by the MEMORY_COMPRESSION_TASK prompt template.
+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.ExtractText()))
+ sb.WriteString(" \n")
+ sb.WriteString("")
+ if i < len(msgs)-1 {
+ sb.WriteString("\n")
+ }
+ }
+ return sb.String()
+}
+
+// copyMessages creates a shallow copy of a message slice.
+func copyMessages(msgs []llm.Message) []llm.Message {
+ out := make([]llm.Message, len(msgs))
+ copy(out, msgs)
+ return out
+}
+
+// runCompression performs three-zone memory compression on the given
+// messages, summarizing the compress zone while preserving the active zone
+// intact. Returns rebuilt as [frozen] + [compressed_summary appended to
+// the user prompt] + [active].
+func (r *Runner) runCompression(ctx context.Context, msgs []llm.Message, filePath string) ([]llm.Message, error) {
+ if len(r.deps.Template.MemoryCompressionTask.Messages) == 0 || len(msgs) <= 2 {
+ return msgs[:min(len(msgs), 2)], nil
+ }
+
+ part := partitionMessages(msgs, r.deps.Template.MaxTokens, 0)
+ if part.compressEnd <= part.frozenEnd {
+ return msgs, nil
+ }
+
+ contextXML := buildMessageXML(msgs[part.frozenEnd:part.compressEnd])
+
+ compressionMsgs := make([]llm.Message, 0, len(r.deps.Template.MemoryCompressionTask.Messages))
+ for _, m := range r.deps.Template.MemoryCompressionTask.Messages {
+ content := strings.ReplaceAll(m.Content, "{{context}}", contextXML)
+ compressionMsgs = append(compressionMsgs, llm.NewTextMessage(m.Role, content))
+ }
+
+ startTime := time.Now()
+ resp, err := r.deps.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
+ Model: r.deps.Model,
+ Messages: compressionMsgs,
+ MaxTokens: r.deps.Template.MaxTokens,
+ })
+ duration := time.Since(startTime)
+
+ fs := r.deps.Session.GetOrCreateFileSession(filePath)
+ rec := fs.AppendTaskRecord(session.MemoryCompressionTask, compressionMsgs)
+ if err != nil {
+ rec.SetError(err, duration)
+ fmt.Fprintf(stdout.Writer(), "[ocr] Memory compression failed: %v\n", err)
+ return msgs[:part.frozenEnd], fmt.Errorf("memory compression: %w", err)
+ }
+ rec.SetResponse(resp, duration)
+ if resp.Usage != nil {
+ atomic.AddInt64(&r.totalInputTokens, resp.Usage.PromptTokens)
+ atomic.AddInt64(&r.totalOutputTokens, resp.Usage.CompletionTokens)
+ atomic.AddInt64(&r.totalCacheReadTokens, resp.Usage.CacheReadTokens)
+ atomic.AddInt64(&r.totalCacheWriteTokens, resp.Usage.CacheWriteTokens)
+ }
+
+ rawSummary := stripMarkdownFences(resp.Content())
+ if rawSummary == "" {
+ return msgs[:part.frozenEnd], nil
+ }
+
+ rebuilt := make([]llm.Message, 2)
+ copy(rebuilt, msgs[:2])
+
+ userMsg := rebuilt[1]
+ currentText := userMsg.ExtractText()
+ rebuilt[1] = llm.NewTextMessage(userMsg.Role, currentText+"\n\n\n"+rawSummary+"\n")
+
+ for i := part.compressEnd; i < len(msgs); i++ {
+ rebuilt = append(rebuilt, msgs[i])
+ }
+
+ return rebuilt, nil
+}
+
+// triggerAsyncCompression kicks off a background compression job.
+func (r *Runner) triggerAsyncCompression(ctx context.Context, messages []llm.Message, filePath string) {
+ msgSnapshot := copyMessages(messages)
+
+ asyncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
+
+ job := &compressionJob{done: make(chan struct{}), cancel: cancel}
+ r.compressionMu.Lock()
+ r.pendingJob = job
+ r.compressionMu.Unlock()
+
+ go func() {
+ defer cancel()
+ rebuilt, _ := r.runCompression(asyncCtx, msgSnapshot, filePath)
+
+ r.compressionMu.Lock()
+ defer r.compressionMu.Unlock()
+
+ if r.pendingJob != job {
+ return // cancelled or superseded
+ }
+ job.rebuilt = rebuilt
+ close(job.done)
+ }()
+}
+
+// tryApplyPendingCompression checks whether a background compression has
+// completed and swaps the rebuilt messages into place. Returns true if
+// applied.
+func (r *Runner) tryApplyPendingCompression(messages *[]llm.Message) bool {
+ r.compressionMu.Lock()
+ job := r.pendingJob
+ r.compressionMu.Unlock()
+
+ if job == nil {
+ return false
+ }
+
+ select {
+ case <-job.done:
+ r.compressionMu.Lock()
+ if r.pendingJob == job && job.rebuilt != nil {
+ *messages = job.rebuilt
+ r.pendingJob = nil
+ }
+ r.compressionMu.Unlock()
+ return true
+ default:
+ return false
+ }
+}
+
+// cancelPendingCompression aborts any in-flight background compression.
+func (r *Runner) cancelPendingCompression() {
+ r.compressionMu.Lock()
+ defer r.compressionMu.Unlock()
+
+ if r.pendingJob != nil {
+ r.pendingJob.cancel()
+ r.pendingJob = nil
+ }
+}
diff --git a/internal/llmloop/loop.go b/internal/llmloop/loop.go
new file mode 100644
index 0000000..5832c6a
--- /dev/null
+++ b/internal/llmloop/loop.go
@@ -0,0 +1,404 @@
+package llmloop
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/open-code-review/open-code-review/internal/config/template"
+ "github.com/open-code-review/open-code-review/internal/diff"
+ "github.com/open-code-review/open-code-review/internal/llm"
+ "github.com/open-code-review/open-code-review/internal/model"
+ "github.com/open-code-review/open-code-review/internal/session"
+ "github.com/open-code-review/open-code-review/internal/stdout"
+ "github.com/open-code-review/open-code-review/internal/telemetry"
+ "github.com/open-code-review/open-code-review/internal/tool"
+)
+
+// Deps bundles all per-call dependencies the Runner needs. Both
+// internal/agent (diff review) and internal/scan (full-file scan) build a
+// Deps from their own state and hand it to NewRunner.
+type Deps struct {
+ LLMClient llm.LLMClient
+ Model string
+ Template template.Template
+ Tools *tool.Registry
+ MainToolDefs []llm.ToolDef
+ CommentCollector *tool.CommentCollector
+ CommentWorkerPool *CommentWorkerPool
+ Session *session.SessionHistory
+ // DiffLookup is consulted by the code_comment tool path to resolve
+ // line numbers against the file's diff (or against full file content
+ // in scan mode — scan adapters return a synthetic Diff whose
+ // NewFileContent is the whole file and Diff is empty).
+ DiffLookup func(path string) *model.Diff
+}
+
+// Runner is a per-session (across files) executor of the LLM tool-use
+// loop. Token counters, warnings, and the optional background compression
+// job are aggregated across every RunPerFile call.
+type Runner struct {
+ deps Deps
+ totalInputTokens int64 // atomically updated
+ totalOutputTokens int64
+ totalCacheReadTokens int64
+ totalCacheWriteTokens int64
+ warningsMu sync.Mutex
+ warnings []AgentWarning
+ compressionMu sync.Mutex
+ pendingJob *compressionJob
+}
+
+// NewRunner returns a Runner bound to the given dependencies.
+func NewRunner(deps Deps) *Runner {
+ return &Runner{deps: deps}
+}
+
+// TotalInputTokens returns the accumulated input/prompt tokens from all LLM calls.
+func (r *Runner) TotalInputTokens() int64 { return atomic.LoadInt64(&r.totalInputTokens) }
+
+// TotalOutputTokens returns the accumulated completion tokens from all LLM calls.
+func (r *Runner) TotalOutputTokens() int64 { return atomic.LoadInt64(&r.totalOutputTokens) }
+
+// TotalCacheReadTokens returns the accumulated cache read tokens.
+func (r *Runner) TotalCacheReadTokens() int64 { return atomic.LoadInt64(&r.totalCacheReadTokens) }
+
+// TotalCacheWriteTokens returns the accumulated cache write tokens.
+func (r *Runner) TotalCacheWriteTokens() int64 { return atomic.LoadInt64(&r.totalCacheWriteTokens) }
+
+// TotalTokensUsed returns input + output.
+func (r *Runner) TotalTokensUsed() int64 {
+ return r.TotalInputTokens() + r.TotalOutputTokens()
+}
+
+// Warnings returns a copy of the accumulated warnings.
+func (r *Runner) Warnings() []AgentWarning {
+ r.warningsMu.Lock()
+ defer r.warningsMu.Unlock()
+ out := make([]AgentWarning, len(r.warnings))
+ copy(out, r.warnings)
+ return out
+}
+
+// RecordWarning adds a non-fatal warning.
+func (r *Runner) RecordWarning(warningType, file, message string) {
+ r.warningsMu.Lock()
+ r.warnings = append(r.warnings, AgentWarning{
+ File: file,
+ Message: message,
+ Type: warningType,
+ })
+ r.warningsMu.Unlock()
+}
+
+// RecordUsage adds the prompt/completion/cache tokens reported by an LLM
+// response to the runner's aggregate counters. Used by callers (plan phase
+// in agent / future scan phases) that perform their own LLM calls outside
+// RunPerFile.
+func (r *Runner) RecordUsage(u *llm.UsageInfo) {
+ if u == nil {
+ return
+ }
+ atomic.AddInt64(&r.totalInputTokens, u.PromptTokens)
+ atomic.AddInt64(&r.totalOutputTokens, u.CompletionTokens)
+ atomic.AddInt64(&r.totalCacheReadTokens, u.CacheReadTokens)
+ atomic.AddInt64(&r.totalCacheWriteTokens, u.CacheWriteTokens)
+}
+
+// CollectPendingComments awaits any async comment-processing workers and
+// returns the aggregated comments from the collector. Safe to call once
+// per session at the end.
+func (r *Runner) CollectPendingComments() []model.LlmComment {
+ if r.deps.CommentWorkerPool != nil {
+ r.deps.CommentWorkerPool.Await()
+ }
+ return r.deps.CommentCollector.Comments()
+}
+
+// RunPerFile drives the main LLM conversation loop for a single file.
+// It sends messages with the configured tool definitions, executes any
+// tool calls returned by the model, and collects review comments until
+// task_done is called or limits are reached. Token usage and warnings
+// are aggregated on the Runner across all files.
+func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath string) error {
+ toolReqCount := r.deps.Template.MaxToolRequestTimes
+ const maxConsecutiveEmptyRounds = 3
+ consecutiveEmptyRounds := 0
+
+ for toolReqCount > 0 {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ toolReqCount--
+
+ fs := r.deps.Session.GetOrCreateFileSession(newPath)
+ rec := fs.AppendTaskRecord(session.MainTask, append([]llm.Message(nil), messages...))
+ startTime := time.Now()
+
+ resp, err := r.deps.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
+ Model: r.deps.Model,
+ Messages: messages,
+ Tools: r.deps.MainToolDefs,
+ MaxTokens: r.deps.Template.MaxTokens,
+ })
+ duration := time.Since(startTime)
+ if err != nil {
+ rec.SetError(err, duration)
+ telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, 0, "error")
+ return fmt.Errorf("LLM completion error: %w", err)
+ }
+ rec.SetResponse(resp, duration)
+ totalTokens := int64(0)
+ if resp.Usage != nil {
+ totalTokens = resp.Usage.TotalTokens
+ atomic.AddInt64(&r.totalInputTokens, resp.Usage.PromptTokens)
+ atomic.AddInt64(&r.totalOutputTokens, resp.Usage.CompletionTokens)
+ atomic.AddInt64(&r.totalCacheReadTokens, resp.Usage.CacheReadTokens)
+ atomic.AddInt64(&r.totalCacheWriteTokens, resp.Usage.CacheWriteTokens)
+ }
+ telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, totalTokens, "ok")
+
+ content := resp.Content()
+ calls := resp.ToolCalls()
+
+ if len(calls) == 0 {
+ fmt.Fprintf(stdout.Writer(), "[ocr] No tool calls parsed for %s, retrying...\n", newPath)
+ messages = append(messages, llm.NewTextMessage("user", "You did not successfully call any tools. Please try again or use task_done if finished."))
+ if content != "" {
+ messages = append(messages[:len(messages)-1], llm.NewTextMessage("assistant", content), messages[len(messages)-1])
+ }
+ continue
+ }
+
+ var results []tool.ToolCallResult
+ taskCompleted := false
+ hasValidResult := false
+
+ for _, call := range calls {
+ cp := r.executeToolCall(ctx, newPath, call, rec)
+ 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 {
+ consecutiveEmptyRounds++
+ if consecutiveEmptyRounds >= maxConsecutiveEmptyRounds {
+ fmt.Fprintf(stdout.Writer(), "[ocr] Too many empty retries for %s, stopping.\n", newPath)
+ break
+ }
+ fmt.Fprintf(stdout.Writer(), "[ocr] No valid tool results for %s, retrying...\n", newPath)
+ } else {
+ consecutiveEmptyRounds = 0
+ }
+
+ succeed := r.addNextMessage(ctx, content, calls, results, &messages, newPath)
+ if !succeed {
+ fmt.Fprintf(stdout.Writer(), "[ocr] Context compression exceeded threshold for %s, stopping.\n", newPath)
+ break
+ }
+ }
+
+ if toolReqCount <= 0 {
+ fmt.Fprintf(stdout.Writer(), "[ocr] Max tool requests reached for %s.\n", newPath)
+ }
+ return nil
+}
+
+// executeToolCall dispatches a single tool call from the LLM response and
+// records the result in session history. code_comment handling includes
+// optional async dispatch through CommentWorkerPool plus line-number
+// resolution / re-location.
+func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.ToolCall, rec *session.TaskRecord) tool.TaskCheckpoint {
+ t := tool.OfName(call.Function.Name)
+ if !t.IsKnown() {
+ return tool.Of(tool.NotAvailableMsg)
+ }
+
+ if t == tool.TaskDone {
+ return tool.Complete()
+ }
+
+ p := lookupTool(r.deps.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.Name(), err))
+ }
+
+ // Inject current file path as default for code_comment when not provided.
+ if t == tool.CodeComment && newPath != "" {
+ if _, ok := args["path"]; !ok {
+ args["path"] = newPath
+ }
+ }
+
+ startTime := time.Now()
+
+ if t == tool.CodeComment {
+ telemetry.PrintToolCallStarted(t.Name(), args)
+
+ comments, errMsg := tool.ParseComments(args)
+ if errMsg != "" {
+ telemetry.RecordToolCall(ctx, t.Name(), time.Since(startTime), false)
+ return tool.Of(errMsg)
+ }
+
+ resolveAndCollect := func(rctx context.Context) {
+ for i := range comments {
+ cm := &comments[i]
+ var d *model.Diff
+ if r.deps.DiffLookup != nil {
+ d = r.deps.DiffLookup(cm.Path)
+ }
+ if d != nil {
+ if !diff.ResolveComment(cm, d) && r.deps.Template.ReLocationTask != nil {
+ rlStart := time.Now()
+ _, resp, msgs := diff.ReLocateComment(rctx, cm, d, r.deps.LLMClient, r.deps.Template.ReLocationTask, r.deps.Model, r.deps.Template.MaxTokens)
+ if msgs != nil {
+ fs := r.deps.Session.GetOrCreateFileSession(cm.Path)
+ rlRec := fs.AppendTaskRecord(session.ReLocationTask, msgs)
+ if resp != nil {
+ rlRec.SetResponse(resp, time.Since(rlStart))
+ if resp.Usage != nil {
+ atomic.AddInt64(&r.totalInputTokens, resp.Usage.PromptTokens)
+ atomic.AddInt64(&r.totalOutputTokens, resp.Usage.CompletionTokens)
+ atomic.AddInt64(&r.totalCacheReadTokens, resp.Usage.CacheReadTokens)
+ atomic.AddInt64(&r.totalCacheWriteTokens, resp.Usage.CacheWriteTokens)
+ }
+ } else {
+ rlRec.SetError(fmt.Errorf("re-location LLM call failed"), time.Since(rlStart))
+ }
+ }
+ }
+ }
+ r.deps.CommentCollector.Add(*cm)
+ }
+ }
+
+ if r.deps.CommentWorkerPool != nil {
+ if rec != nil {
+ rec.AddToolResult(t.Name(), call.Function.Arguments, "(async)")
+ }
+ pool := r.deps.CommentWorkerPool
+ asyncCtx := context.WithoutCancel(ctx)
+ toolName := t.Name()
+ pool.Submit(func() ([]model.LlmComment, error) {
+ resolveAndCollect(asyncCtx)
+ telemetry.PrintToolCallFinished(toolName, time.Since(startTime))
+ return []model.LlmComment{}, nil
+ })
+ telemetry.RecordToolCall(asyncCtx, toolName, time.Since(startTime), true)
+ return tool.Of(tool.CommentSucceed)
+ }
+
+ resolveAndCollect(ctx)
+ dur := time.Since(startTime)
+ telemetry.RecordToolCall(ctx, t.Name(), dur, true)
+ telemetry.PrintToolCallFinished(t.Name(), dur)
+ if rec != nil {
+ rec.AddToolResult(t.Name(), call.Function.Arguments, tool.CommentSucceed)
+ }
+ return tool.Of(tool.CommentSucceed)
+ }
+
+ // Synchronous path for all other tools
+ telemetry.PrintToolCallStarted(t.Name(), args)
+ result, err := p.Execute(ctx, args)
+ dur := time.Since(startTime)
+ ok := err == nil
+ telemetry.RecordToolCall(ctx, t.Name(), dur, ok)
+
+ if err != nil {
+ telemetry.PrintToolCallError(t.Name(), err)
+ return tool.Of(fmt.Sprintf("Error executing tool %s: %v", t.Name(), err))
+ }
+ telemetry.PrintToolCallFinished(t.Name(), dur)
+ if rec != nil {
+ rec.AddToolResult(t.Name(), call.Function.Arguments, result)
+ }
+ return tool.Of(result)
+}
+
+// addNextMessage extends the conversation with the assistant message and
+// tool responses, applying three-zone compression at the soft (60%) and
+// warning (80%) MaxTokens thresholds. Returns false when even after
+// synchronous compression the conversation is still over the warning
+// threshold — caller should stop the loop in that case.
+func (r *Runner) addNextMessage(ctx context.Context, assistantContent string, toolCalls []llm.ToolCall, results []tool.ToolCallResult, messages *[]llm.Message, filePath string) bool {
+ maxAllowed := r.deps.Template.MaxTokens
+ softLimit := int(float64(maxAllowed) * tokenSoftThreshold)
+ warnLimit := int(float64(maxAllowed) * tokenWarningThreshold)
+
+ r.tryApplyPendingCompression(messages)
+
+ tokenCount := CountMessagesTokens(*messages)
+
+ if tokenCount > warnLimit {
+ r.cancelPendingCompression()
+ *messages, _ = r.runCompression(ctx, *messages, filePath)
+ tokenCount = CountMessagesTokens(*messages)
+ }
+
+ if tokenCount > softLimit && r.pendingJob == nil {
+ r.triggerAsyncCompression(ctx, *messages, filePath)
+ }
+
+ if len(toolCalls) > 0 {
+ *messages = append(*messages, llm.NewToolCallMessage(assistantContent, toolCalls))
+ } else if assistantContent != "" {
+ *messages = append(*messages, llm.NewTextMessage("assistant", assistantContent))
+ }
+
+ for _, rs := range results {
+ *messages = append(*messages, llm.NewToolResultMessage(rs.ToolCallID, rs.Result))
+ }
+
+ finalCount := CountMessagesTokens(*messages)
+ if finalCount > warnLimit {
+ r.cancelPendingCompression()
+ *messages, _ = r.runCompression(ctx, *messages, filePath)
+ }
+
+ return CountMessagesTokens(*messages) < warnLimit
+}
+
+// lookupTool returns the provider for a given tool from the registry, or
+// nil when not registered.
+func lookupTool(reg *tool.Registry, t tool.Tool) tool.Provider {
+ p, ok := reg.Get(t.Name())
+ if !ok {
+ return nil
+ }
+ return p
+}
diff --git a/internal/llmloop/pool.go b/internal/llmloop/pool.go
new file mode 100644
index 0000000..3df2ea5
--- /dev/null
+++ b/internal/llmloop/pool.go
@@ -0,0 +1,74 @@
+// Package llmloop carries the per-file LLM tool-use loop shared by `ocr
+// review` (diff-based) and `ocr scan` (full-file). It owns the chat
+// completion conversation state, three-zone memory compression, tool-call
+// dispatch (including async comment post-processing), and aggregate token /
+// warning bookkeeping. Callers above this package render the initial
+// messages (review uses MAIN_TASK, scan uses FULL_SCAN_TASK) and hand them
+// in via Runner.RunPerFile.
+package llmloop
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/open-code-review/open-code-review/internal/model"
+ "github.com/open-code-review/open-code-review/internal/stdout"
+)
+
+// AgentWarning describes a non-fatal warning recorded during a per-file
+// review/scan. The name is kept for backwards compatibility with the
+// previous internal/agent package.
+type AgentWarning struct {
+ File string `json:"file"`
+ Message string `json:"message"`
+ Type string `json:"type"`
+}
+
+// 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.
+//
+// Offloading them to a worker pool keeps the main LLM tool-use loop
+// unblocked, reducing overall latency — mirroring the Java side's dedicated
+// subtaskExecutor for the CODE_COMMENT tool.
+type CommentWorkerPool struct {
+ semaphore chan struct{}
+ wg sync.WaitGroup
+ resultsMu sync.Mutex
+ results []model.LlmComment
+}
+
+// NewCommentWorkerPool creates a pool with the given concurrency limit.
+// workerCount <= 0 defaults to 8.
+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.Go(func() {
+ p.semaphore <- struct{}{}
+ defer func() { <-p.semaphore }()
+
+ comments, err := f()
+ if err != nil {
+ fmt.Fprintf(stdout.Writer(), "[ocr] CommentWorkerPool error: %v\n", err)
+ }
+ p.resultsMu.Lock()
+ p.results = append(p.results, comments...)
+ p.resultsMu.Unlock()
+ })
+}
+
+// Await blocks until all submitted work has completed and returns
+// aggregated results from every Submit call so far.
+func (p *CommentWorkerPool) Await() []model.LlmComment {
+ p.wg.Wait()
+ return p.results
+}
diff --git a/internal/model/preview.go b/internal/model/preview.go
new file mode 100644
index 0000000..84841eb
--- /dev/null
+++ b/internal/model/preview.go
@@ -0,0 +1,35 @@
+package model
+
+// ExcludeReason describes why a file was excluded from review. Shared by
+// both diff review (internal/agent) and full-file scan (internal/scan).
+type ExcludeReason string
+
+const (
+ ExcludeNone ExcludeReason = ""
+ ExcludeUserRule ExcludeReason = "user_exclude"
+ ExcludeExtension ExcludeReason = "unsupported_ext"
+ ExcludeDefaultPath ExcludeReason = "default_path"
+ ExcludeDeleted ExcludeReason = "deleted"
+ ExcludeBinary ExcludeReason = "binary"
+)
+
+// PreviewEntry is one file's preview record (mode-agnostic).
+type PreviewEntry struct {
+ Path string `json:"path"`
+ Status string `json:"status"`
+ Insertions int64 `json:"insertions"`
+ Deletions int64 `json:"deletions"`
+ WillReview bool `json:"will_review"`
+ ExcludeReason ExcludeReason `json:"exclude_reason,omitempty"`
+}
+
+// Preview is the full preview result, mode-agnostic so cmd/opencodereview
+// can render it the same way for review and scan.
+type Preview struct {
+ Entries []PreviewEntry `json:"files"`
+ TotalInsertions int64 `json:"total_insertions"`
+ TotalDeletions int64 `json:"total_deletions"`
+ TotalFiles int `json:"total_files"`
+ ReviewableCount int `json:"reviewable_count"`
+ ExcludedCount int `json:"excluded_count"`
+}
diff --git a/internal/model/scan.go b/internal/model/scan.go
new file mode 100644
index 0000000..d8d42d4
--- /dev/null
+++ b/internal/model/scan.go
@@ -0,0 +1,30 @@
+package model
+
+// ScanItem represents a single file enumerated by full-scan mode. Unlike
+// model.Diff (which carries a unified diff text), ScanItem carries the
+// entire file content because scan reviews whole files with no diff
+// context.
+type ScanItem struct {
+ Path string `json:"path"`
+ Content string `json:"content"`
+ IsBinary bool `json:"is_binary,omitempty"`
+ LineCount int `json:"line_count,omitempty"`
+}
+
+// AsDiff returns a Diff suitable for handing to code that expects the
+// diff-based shape (line-number resolver, file_read_diff tool). The Diff
+// field stays empty since scan mode has no unified diff; NewFileContent
+// carries the whole file so resolver.resolveFromFileContent and similar
+// fallbacks can still find the source lines.
+func (s *ScanItem) AsDiff() *Diff {
+ if s == nil {
+ return nil
+ }
+ return &Diff{
+ OldPath: s.Path,
+ NewPath: s.Path,
+ NewFileContent: s.Content,
+ IsBinary: s.IsBinary,
+ Insertions: int64(s.LineCount),
+ }
+}
diff --git a/internal/scan/agent.go b/internal/scan/agent.go
new file mode 100644
index 0000000..5d98719
--- /dev/null
+++ b/internal/scan/agent.go
@@ -0,0 +1,418 @@
+package scan
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ allowedext "github.com/open-code-review/open-code-review/internal/config/allowlist"
+ "github.com/open-code-review/open-code-review/internal/config/rules"
+ "github.com/open-code-review/open-code-review/internal/config/template"
+ "github.com/open-code-review/open-code-review/internal/gitcmd"
+ "github.com/open-code-review/open-code-review/internal/llm"
+ "github.com/open-code-review/open-code-review/internal/llmloop"
+ "github.com/open-code-review/open-code-review/internal/model"
+ "github.com/open-code-review/open-code-review/internal/session"
+ "github.com/open-code-review/open-code-review/internal/stdout"
+ "github.com/open-code-review/open-code-review/internal/telemetry"
+ "github.com/open-code-review/open-code-review/internal/tool"
+)
+
+// changeFilesScanLiteral substitutes for the {{change_files}} placeholder.
+// Full-scan has no "other changed files" concept; using a fixed sentinel is
+// less misleading than leaving the placeholder empty.
+const changeFilesScanLiteral = "(not applicable in full-scan mode)"
+
+// Args bundles all dependencies needed for one scan session. Mirrors the
+// fields of internal/agent.Args that scan actually uses, minus diff-only
+// concerns (From / To / Commit / PlanToolDefs).
+type Args struct {
+ RepoDir string
+ Paths []string // empty = whole repo
+ Template template.Template
+ SystemRule rules.Resolver
+ FileFilter *rules.FileFilter
+ LLMClient llm.LLMClient
+ Tools *tool.Registry
+ MainToolDefs []llm.ToolDef
+ CommentCollector *tool.CommentCollector
+ CommentWorkerPool *llmloop.CommentWorkerPool
+ MaxConcurrency int
+ ConcurrentTaskTimeout int
+ Model string
+ Background string
+ GitRunner *gitcmd.Runner
+ Session *session.SessionHistory
+}
+
+// Agent orchestrates full-file code review. It delegates the per-file LLM
+// tool-use loop to llmloop.Runner and owns only scan-specific concerns
+// (file enumeration, FULL_SCAN_TASK rendering, per-file filtering).
+type Agent struct {
+ args Args
+ items []model.ScanItem
+ currentDate string
+ session *session.SessionHistory
+ subtaskFailed int64 // atomic
+ runner *llmloop.Runner
+}
+
+// NewAgent creates a scan Agent from the given args. The Session is
+// auto-created (review_mode = full_scan) when not supplied.
+func NewAgent(args Args) *Agent {
+ if args.Tools == nil {
+ args.Tools = tool.NewRegistry()
+ }
+ if args.CommentCollector == nil {
+ args.CommentCollector = tool.NewCommentCollector()
+ }
+ if args.Session == nil {
+ args.Session = session.New(args.RepoDir, "", args.Model, session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ })
+ }
+ a := &Agent{
+ args: args,
+ session: args.Session,
+ }
+ a.runner = llmloop.NewRunner(llmloop.Deps{
+ LLMClient: args.LLMClient,
+ Model: args.Model,
+ Template: args.Template,
+ Tools: args.Tools,
+ MainToolDefs: args.MainToolDefs,
+ CommentCollector: args.CommentCollector,
+ CommentWorkerPool: args.CommentWorkerPool,
+ Session: args.Session,
+ // DiffLookup returns a synthetic Diff so the code_comment tool's
+ // line-number resolver (resolveFromFileContent) can match against
+ // the full file content of the scanned file.
+ DiffLookup: a.lookupDiff,
+ })
+ return a
+}
+
+// Session returns the session history associated with this Agent.
+func (a *Agent) Session() *session.SessionHistory { return a.session }
+
+// FilesReviewed returns the number of items included in this scan.
+func (a *Agent) FilesReviewed() int64 { return int64(len(a.items)) }
+
+// Diffs returns the scanned items adapted to model.Diff form so callers
+// (e.g. cmd/opencodereview's outputJSON / ResolveLineNumbers) can treat
+// both review and scan results uniformly.
+func (a *Agent) Diffs() []model.Diff {
+ out := make([]model.Diff, len(a.items))
+ for i := range a.items {
+ out[i] = *a.items[i].AsDiff()
+ }
+ return out
+}
+
+// TotalTokensUsed / TotalInputTokens / ... delegate to the underlying runner.
+func (a *Agent) TotalTokensUsed() int64 { return a.runner.TotalTokensUsed() }
+func (a *Agent) TotalInputTokens() int64 { return a.runner.TotalInputTokens() }
+func (a *Agent) TotalOutputTokens() int64 { return a.runner.TotalOutputTokens() }
+func (a *Agent) TotalCacheReadTokens() int64 { return a.runner.TotalCacheReadTokens() }
+func (a *Agent) TotalCacheWriteTokens() int64 {
+ return a.runner.TotalCacheWriteTokens()
+}
+
+// Warnings returns the warnings recorded by the LLM runner.
+func (a *Agent) Warnings() []llmloop.AgentWarning { return a.runner.Warnings() }
+
+func (a *Agent) recordWarning(warningType, file, message string) {
+ a.runner.RecordWarning(warningType, file, message)
+}
+
+// Run executes the full-scan pipeline: enumerate → filter → token-filter →
+// dispatch one subtask per file → collect comments.
+func (a *Agent) Run(ctx context.Context) ([]model.LlmComment, error) {
+ if a.args.Template.FullScanTask == nil || len(a.args.Template.FullScanTask.Messages) == 0 {
+ return nil, fmt.Errorf("FULL_SCAN_TASK template is missing or empty")
+ }
+
+ ctx, scanSpan := telemetry.StartSpan(ctx, "scan.enumerate")
+ provider := NewProvider(a.args.RepoDir, a.args.Paths, a.args.GitRunner)
+ items, err := provider.Enumerate(ctx)
+ if err != nil {
+ scanSpan.End()
+ return nil, fmt.Errorf("enumerate files: %w", err)
+ }
+ telemetry.SetAttr(scanSpan, "files.enumerated", len(items))
+ scanSpan.End()
+
+ a.items = items
+ a.injectScanContentMap()
+ a.args.Tools.Freeze()
+
+ totalDiscovered := len(a.items)
+ a.items = a.filterScanItems(a.items)
+ a.items = a.filterLargeScans(a.items)
+
+ reviewable := len(a.items)
+ fmt.Fprintf(stdout.Writer(), "[ocr] full-scan: %d file(s) discovered, reviewing %d in %s\n",
+ totalDiscovered, reviewable, a.args.RepoDir)
+
+ if reviewable == 0 {
+ fmt.Fprintln(stdout.Writer(), "[ocr] No reviewable files. Skipping scan.")
+ telemetry.Event(ctx, "scan.no.files")
+ a.session.Finalize()
+ return []model.LlmComment{}, nil
+ }
+
+ a.currentDate = time.Now().Format("2006-01-02 15:04")
+ telemetry.Event(ctx, "scan.started",
+ telemetry.AnyToAttr("file.count", totalDiscovered),
+ telemetry.AnyToAttr("review.count", reviewable),
+ telemetry.AnyToAttr("repo.dir", a.args.RepoDir))
+ telemetry.RecordFilesReviewed(ctx, int64(reviewable))
+
+ comments, err := a.dispatchSubtasks(ctx)
+ if len(comments) > 0 {
+ telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
+ }
+ a.session.Finalize()
+ return comments, err
+}
+
+// lookupDiff returns the synthetic Diff for a path, used by llmloop.Runner
+// to resolve code_comment line numbers against the scanned file content.
+func (a *Agent) lookupDiff(path string) *model.Diff {
+ for i := range a.items {
+ if a.items[i].Path == path {
+ return a.items[i].AsDiff()
+ }
+ }
+ return nil
+}
+
+// injectScanContentMap fills the file_read_diff tool's DiffMap with full
+// file content keyed by path, so if the model calls it the tool returns
+// the whole file rather than failing.
+func (a *Agent) injectScanContentMap() {
+ m := make(map[string]string, len(a.items))
+ for i := range a.items {
+ it := &a.items[i]
+ if it.Path != "" {
+ m[it.Path] = it.Content
+ }
+ }
+ dm := tool.NewDiffMap(m)
+ if p, ok := a.args.Tools.Get(tool.FileReadDiff.Name()); ok {
+ if frd, ok := p.(*tool.FileReadDiffProvider); ok {
+ frd.SetDiffMap(dm)
+ }
+ }
+}
+
+// filterScanItems drops items that should not be reviewed under the standard
+// reviewability rules (binary, extension allowlist, user include/exclude,
+// default excluded paths).
+func (a *Agent) filterScanItems(items []model.ScanItem) []model.ScanItem {
+ var kept []model.ScanItem
+ skipped := 0
+ for _, it := range items {
+ if reason := a.whyExcluded(it); reason != model.ExcludeNone {
+ if it.IsBinary {
+ fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s — binary file\n", it.Path)
+ } else {
+ fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s — filtered by path/extension rules\n", it.Path)
+ }
+ skipped++
+ continue
+ }
+ kept = append(kept, it)
+ }
+ if skipped > 0 {
+ fmt.Fprintf(stdout.Writer(), "[ocr] Filtered %d file(s) by include/exclude rules\n", skipped)
+ }
+ return kept
+}
+
+// filterLargeScans drops items whose content exceeds 80% of MaxTokens.
+func (a *Agent) filterLargeScans(items []model.ScanItem) []model.ScanItem {
+ limit := a.args.Template.MaxTokens * 4 / 5
+ if limit <= 0 {
+ return items
+ }
+ var kept []model.ScanItem
+ skipped := 0
+ for _, it := range items {
+ tokens := llm.CountTokens(it.Content)
+ if tokens > limit {
+ fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s (~%d tokens exceeds 80%% of max_tokens(%d))\n",
+ it.Path, tokens, a.args.Template.MaxTokens)
+ skipped++
+ continue
+ }
+ kept = append(kept, it)
+ }
+ if skipped > 0 {
+ fmt.Fprintf(stdout.Writer(), "[ocr] Pre-filtered %d file(s) exceeding 80%% of max_tokens\n", skipped)
+ }
+ return kept
+}
+
+// whyExcluded mirrors agent.whyExcluded but for ScanItem inputs.
+func (a *Agent) whyExcluded(it model.ScanItem) model.ExcludeReason {
+ if it.IsBinary {
+ return model.ExcludeBinary
+ }
+ path := it.Path
+ if a.args.FileFilter != nil && a.args.FileFilter.IsUserExcluded(path) {
+ return model.ExcludeUserRule
+ }
+ ext := extFromPath(path)
+ if ext != "" && !allowedext.IsAllowedExt(ext) {
+ return model.ExcludeExtension
+ }
+ if a.args.FileFilter != nil && a.args.FileFilter.HasInclude() && a.args.FileFilter.IsUserIncluded(path) {
+ return model.ExcludeNone
+ }
+ if allowedext.IsExcludedPath(path) {
+ return model.ExcludeDefaultPath
+ }
+ return model.ExcludeNone
+}
+
+func extFromPath(path string) string {
+ basename := path
+ if idx := strings.LastIndex(path, "/"); idx >= 0 {
+ basename = path[idx+1:]
+ }
+ dot := strings.LastIndex(basename, ".")
+ if dot <= 0 {
+ return ""
+ }
+ return strings.ToLower(basename[dot:])
+}
+
+// dispatchSubtasks fans out one subtask per item with a bounded goroutine
+// pool. Mirrors the agent's dispatchSubtasks structure but works on
+// ScanItem and delegates the per-file LLM loop to llmloop.Runner.
+func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error) {
+ startTime := time.Now()
+ defer func() {
+ telemetry.RecordReviewDuration(ctx, time.Since(startTime))
+ }()
+
+ if len(a.items) == 0 {
+ return []model.LlmComment{}, nil
+ }
+
+ atomic.StoreInt64(&a.subtaskFailed, 0)
+
+ concurrency := a.args.MaxConcurrency
+ if concurrency <= 0 {
+ concurrency = 8
+ }
+ sem := make(chan struct{}, concurrency)
+ timeout := time.Duration(a.args.ConcurrentTaskTimeout) * time.Minute
+
+ var (
+ wg sync.WaitGroup
+ dispatched int64
+ )
+
+ for i := range a.items {
+ select {
+ case sem <- struct{}{}:
+ case <-ctx.Done():
+ wg.Wait()
+ return a.args.CommentCollector.Comments(), ctx.Err()
+ }
+
+ dispatched++
+ wg.Add(1)
+ go func(it model.ScanItem) {
+ defer wg.Done()
+ defer func() { <-sem }()
+
+ var fileCtx context.Context
+ var cancel context.CancelFunc
+ if timeout > 0 {
+ fileCtx, cancel = context.WithTimeout(ctx, timeout)
+ defer cancel()
+ } else {
+ fileCtx = ctx
+ }
+
+ if err := a.executeSubtask(fileCtx, it); err != nil {
+ atomic.AddInt64(&a.subtaskFailed, 1)
+ fmt.Fprintf(stdout.Writer(), "[ocr] Scan subtask error for %s: %v\n", it.Path, err)
+ telemetry.ErrorEvent(fileCtx, "scan.subtask.error", err,
+ telemetry.AnyToAttr("file.path", it.Path))
+ a.recordWarning("scan_subtask_error", it.Path, err.Error())
+ }
+ }(a.items[i])
+ }
+
+ wg.Wait()
+
+ if a.args.CommentWorkerPool != nil {
+ a.args.CommentWorkerPool.Await()
+ }
+
+ failed := atomic.LoadInt64(&a.subtaskFailed)
+ if failed > 0 && failed == dispatched {
+ return nil, fmt.Errorf("all %d file scan(s) failed — check your LLM configuration and API key", dispatched)
+ }
+ return a.args.CommentCollector.Comments(), nil
+}
+
+// executeSubtask renders the FULL_SCAN_TASK template for one item and
+// invokes the shared LLM loop. Plan phase is intentionally skipped.
+func (a *Agent) executeSubtask(ctx context.Context, it model.ScanItem) error {
+ ctx, span := telemetry.StartSpan(ctx, "scan.subtask."+it.Path)
+ defer span.End()
+ telemetry.SetAttr(span, "file.path", it.Path)
+
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ rule := ""
+ if a.args.SystemRule != nil {
+ rule = a.args.SystemRule.Resolve(strings.ToLower(it.Path))
+ }
+
+ messages := a.renderMessages(it, rule)
+
+ tokenCount := llmloop.CountMessagesTokens(messages)
+ maxAllowed := a.args.Template.MaxTokens
+ tokenLimit := maxAllowed * 4 / 5
+ if tokenCount > tokenLimit {
+ msg := fmt.Sprintf("prompt tokens (%d) exceed %d%% of max_tokens(%d)", tokenCount, 80, maxAllowed)
+ fmt.Fprintf(stdout.Writer(), "[ocr] WARNING: %s for %s\n", msg, it.Path)
+ a.recordWarning("token_threshold_exceeded", it.Path, msg)
+ telemetry.Event(ctx, "token.threshold.exceeded",
+ telemetry.AnyToAttr("file.path", it.Path),
+ telemetry.AnyToAttr("tokens", tokenCount),
+ telemetry.AnyToAttr("max_tokens", maxAllowed))
+ return nil
+ }
+
+ return a.runner.RunPerFile(ctx, messages, it.Path)
+}
+
+// renderMessages substitutes placeholders in the FULL_SCAN_TASK template
+// for a single scan item.
+func (a *Agent) renderMessages(it model.ScanItem, rule string) []llm.Message {
+ rawMsgs := a.args.Template.FullScanTask.Messages
+ messages := make([]llm.Message, 0, len(rawMsgs))
+ for _, m := range rawMsgs {
+ content := m.Content
+ content = strings.ReplaceAll(content, "{{current_system_date_time}}", a.currentDate)
+ content = strings.ReplaceAll(content, "{{current_file_path}}", it.Path)
+ content = strings.ReplaceAll(content, "{{system_rule}}", rule)
+ content = strings.ReplaceAll(content, "{{change_files}}", changeFilesScanLiteral)
+ content = strings.ReplaceAll(content, "{{file_content}}", it.Content)
+ content = strings.ReplaceAll(content, "{{requirement_background}}", a.args.Background)
+ messages = append(messages, llm.NewTextMessage(m.Role, content))
+ }
+ return messages
+}
diff --git a/internal/scan/agent_test.go b/internal/scan/agent_test.go
new file mode 100644
index 0000000..0d806cc
--- /dev/null
+++ b/internal/scan/agent_test.go
@@ -0,0 +1,185 @@
+package scan
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/open-code-review/open-code-review/internal/config/template"
+ "github.com/open-code-review/open-code-review/internal/llmloop"
+ "github.com/open-code-review/open-code-review/internal/model"
+ "github.com/open-code-review/open-code-review/internal/session"
+ "github.com/open-code-review/open-code-review/internal/tool"
+)
+
+func newAgentForTest(t *testing.T, tpl template.Template) *Agent {
+ t.Helper()
+ return NewAgent(Args{
+ Template: tpl,
+ CommentCollector: tool.NewCommentCollector(),
+ Tools: tool.NewRegistry(),
+ Session: session.New(t.TempDir(), "main", "test-model", session.SessionOptions{
+ ReviewMode: session.ReviewModeFullScan,
+ }),
+ })
+}
+
+func makeTemplateWithFullScan() template.Template {
+ return template.Template{
+ MaxTokens: 1000,
+ MaxToolRequestTimes: 5,
+ MainTask: template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "system", Content: "main"},
+ {Role: "user", Content: "main user"},
+ },
+ },
+ FullScanTask: &template.LlmConversation{
+ Messages: []template.ChatMessage{
+ {Role: "system", Content: "scan system rule={{system_rule}}"},
+ {
+ Role: "user",
+ Content: "path={{current_file_path}}\n" +
+ "date={{current_system_date_time}}\n" +
+ "siblings=[{{change_files}}]\n" +
+ "bg={{requirement_background}}\n" +
+ "\n{{file_content}}\n",
+ },
+ },
+ },
+ }
+}
+
+func TestRenderMessages(t *testing.T) {
+ tpl := makeTemplateWithFullScan()
+ a := newAgentForTest(t, tpl)
+ a.currentDate = "2026-06-09 10:00"
+ a.args.Background = "ticket-123"
+
+ it := model.ScanItem{
+ Path: "internal/foo/bar.go",
+ Content: "package foo\n\nfunc Bar() {}\n",
+ }
+ msgs := a.renderMessages(it, "rule-text")
+
+ if len(msgs) != 2 {
+ t.Fatalf("expected 2 messages, got %d", len(msgs))
+ }
+
+ sysText := msgs[0].ExtractText()
+ if !strings.Contains(sysText, "rule=rule-text") {
+ t.Errorf("system missing system_rule: %q", sysText)
+ }
+
+ userText := msgs[1].ExtractText()
+ checks := map[string]string{
+ "path": "path=internal/foo/bar.go",
+ "date": "date=2026-06-09 10:00",
+ "siblings": "siblings=[" + changeFilesScanLiteral + "]",
+ "bg": "bg=ticket-123",
+ "content": "\npackage foo\n\nfunc Bar() {}\n\n",
+ }
+ for label, want := range checks {
+ if !strings.Contains(userText, want) {
+ t.Errorf("%s missing %q\nfull: %q", label, want, userText)
+ }
+ }
+ for _, leak := range []string{"{{diff}}", "{{file_content}}", "{{change_files}}", "{{plan_guidance}}"} {
+ if strings.Contains(userText, leak) {
+ t.Errorf("placeholder %s leaked into prompt", leak)
+ }
+ }
+}
+
+func TestFilterLargeScans(t *testing.T) {
+ tpl := makeTemplateWithFullScan()
+ tpl.MaxTokens = 40 // threshold = 32
+ a := newAgentForTest(t, tpl)
+
+ short := strings.Repeat("a ", 5)
+ huge := strings.Repeat("token ", 200)
+ in := []model.ScanItem{
+ {Path: "a.go", Content: short},
+ {Path: "huge.go", Content: huge},
+ {Path: "b.go", Content: short},
+ }
+ out := a.filterLargeScans(in)
+ if len(out) != 2 {
+ t.Fatalf("expected 2 kept, got %d", len(out))
+ }
+ for _, it := range out {
+ if it.Path == "huge.go" {
+ t.Errorf("huge.go should have been filtered")
+ }
+ }
+}
+
+func TestFilterLargeScans_NoLimit(t *testing.T) {
+ tpl := makeTemplateWithFullScan()
+ tpl.MaxTokens = 0
+ a := newAgentForTest(t, tpl)
+ in := []model.ScanItem{
+ {Path: "a.go", Content: "anything"},
+ {Path: "b.go", Content: strings.Repeat("x ", 1000)},
+ }
+ out := a.filterLargeScans(in)
+ if len(out) != 2 {
+ t.Errorf("with MaxTokens=0 nothing should be filtered, got %d", len(out))
+ }
+}
+
+func TestInjectScanContentMap(t *testing.T) {
+ tpl := makeTemplateWithFullScan()
+ a := newAgentForTest(t, tpl)
+ a.args.Tools.Register(tool.NewFileReadDiff(tool.DiffMap{}))
+
+ a.items = []model.ScanItem{
+ {Path: "x.go", Content: "package x"},
+ {Path: "y.go", Content: "package y"},
+ }
+ a.injectScanContentMap()
+
+ p, ok := a.args.Tools.Get(tool.FileReadDiff.Name())
+ if !ok {
+ t.Fatal("file_read_diff not registered")
+ }
+ frd := p.(*tool.FileReadDiffProvider)
+ res, err := frd.Execute(t.Context(), map[string]any{
+ "path_array": []any{"x.go", "y.go", "missing.go"},
+ })
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !strings.Contains(res, "package x") || !strings.Contains(res, "package y") {
+ t.Errorf("missing scan content:\n%s", res)
+ }
+}
+
+func TestNewAgent_SetsSessionMode(t *testing.T) {
+ a := NewAgent(Args{Template: makeTemplateWithFullScan()})
+ if a.session.ReviewMode != session.ReviewModeFullScan {
+ t.Errorf("ReviewMode = %q, want %q", a.session.ReviewMode, session.ReviewModeFullScan)
+ }
+}
+
+func TestRunner_Warnings_RoundTrip(t *testing.T) {
+ a := newAgentForTest(t, makeTemplateWithFullScan())
+ a.recordWarning("foo", "x.go", "boom")
+ ws := a.Warnings()
+ if len(ws) != 1 || ws[0].Type != "foo" || ws[0].File != "x.go" {
+ t.Errorf("warnings = %+v", ws)
+ }
+}
+
+// Ensure llmloop.Runner is the underlying source of token counters so the
+// public methods on scan.Agent are not stale (preventing accidental refactor
+// regressions).
+func TestTokenCountersDelegateToRunner(t *testing.T) {
+ a := newAgentForTest(t, makeTemplateWithFullScan())
+ if a.TotalInputTokens() != a.runner.TotalInputTokens() ||
+ a.TotalOutputTokens() != a.runner.TotalOutputTokens() ||
+ a.TotalCacheReadTokens() != a.runner.TotalCacheReadTokens() ||
+ a.TotalCacheWriteTokens() != a.runner.TotalCacheWriteTokens() {
+ t.Fatal("scan.Agent token getters must mirror runner")
+ }
+ _ = llmloop.AgentWarning{} // keep llmloop import meaningful
+}
diff --git a/internal/scan/preview.go b/internal/scan/preview.go
new file mode 100644
index 0000000..9098fa3
--- /dev/null
+++ b/internal/scan/preview.go
@@ -0,0 +1,43 @@
+package scan
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/open-code-review/open-code-review/internal/model"
+)
+
+// Preview enumerates files and applies the standard reviewability filter
+// without dispatching any LLM calls. Returns a *model.Preview ready for
+// cmd/opencodereview.outputPreviewText to render.
+func (a *Agent) Preview(ctx context.Context) (*model.Preview, error) {
+ provider := NewProvider(a.args.RepoDir, a.args.Paths, a.args.GitRunner)
+ items, err := provider.Enumerate(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("enumerate files: %w", err)
+ }
+ a.items = items
+
+ result := &model.Preview{
+ TotalFiles: len(a.items),
+ }
+
+ for _, it := range a.items {
+ entry := model.PreviewEntry{
+ Path: it.Path,
+ Status: "scan",
+ Insertions: int64(it.LineCount),
+ }
+ reason := a.whyExcluded(it)
+ entry.WillReview = reason == model.ExcludeNone
+ entry.ExcludeReason = reason
+ if entry.WillReview {
+ result.ReviewableCount++
+ result.TotalInsertions += entry.Insertions
+ } else {
+ result.ExcludedCount++
+ }
+ result.Entries = append(result.Entries, entry)
+ }
+ return result, nil
+}
diff --git a/internal/scan/provider.go b/internal/scan/provider.go
new file mode 100644
index 0000000..0f17d5f
--- /dev/null
+++ b/internal/scan/provider.go
@@ -0,0 +1,226 @@
+// Package scan implements `ocr scan` — full-file code review. It owns the
+// file-enumeration provider, the per-file orchestrator, and the FULL_SCAN
+// prompt-template plumbing. Shared LLM tool-use loop / memory compression
+// lives in internal/llmloop; this package only handles scan-specific
+// concerns (enumeration, FULL_SCAN_TASK rendering, scan-specific filter).
+package scan
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/open-code-review/open-code-review/internal/diff"
+ "github.com/open-code-review/open-code-review/internal/gitcmd"
+ "github.com/open-code-review/open-code-review/internal/model"
+)
+
+// binarySniffWindow is the number of leading bytes inspected to decide
+// whether a file is binary. Matches common heuristics (git, less).
+const binarySniffWindow = 8000
+
+// maxScanFileBytes is the hard cap on how large a single file may be
+// before the scanner skips it. Larger files almost always blow past the
+// per-file token budget anyway and reading them just wastes memory.
+const maxScanFileBytes = 5 << 20 // 5 MiB
+
+// Provider enumerates source files in a repository for full-file review.
+// Unlike diff.Provider it produces no unified diffs — each ScanItem carries
+// the full file content via Content, and binaries are emitted as placeholder
+// entries (Content empty, IsBinary=true) so callers can still surface them
+// in previews without spending memory on their bytes.
+type Provider struct {
+ repoDir string
+ paths []string // empty = whole repo
+ runner *gitcmd.Runner
+}
+
+// NewProvider creates a Provider that enumerates the repository at repoDir.
+// If paths is non-empty each element must be a repo-relative path (file or
+// directory); only matching files are returned.
+func NewProvider(repoDir string, paths []string, runner *gitcmd.Runner) *Provider {
+ cleaned := make([]string, 0, len(paths))
+ for _, p := range paths {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ // Normalize: strip leading "./" and trailing "/" so prefix matching
+ // against `git ls-files` output (which never has leading "./") works.
+ p = strings.TrimPrefix(p, "./")
+ p = strings.TrimSuffix(p, "/")
+ cleaned = append(cleaned, filepath.ToSlash(p))
+ }
+ return &Provider{
+ repoDir: repoDir,
+ paths: cleaned,
+ runner: runner,
+ }
+}
+
+// Enumerate returns one ScanItem per reviewable file. Binaries are emitted
+// with empty Content + IsBinary=true so previews can show them as excluded.
+func (p *Provider) Enumerate(ctx context.Context) ([]model.ScanItem, error) {
+ files, err := p.listFiles(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(p.paths) > 0 {
+ files = filterByPaths(files, p.paths)
+ }
+
+ gitignorePatterns := diff.LoadGitignorePatterns(p.repoDir)
+
+ var out []model.ScanItem
+ for _, rel := range files {
+ if rel == "" {
+ continue
+ }
+ if diff.IsPathExcluded(p.repoDir, rel, gitignorePatterns) {
+ continue
+ }
+ full := filepath.Join(p.repoDir, rel)
+ info, err := os.Lstat(full)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot stat %s: %v\n", rel, err)
+ continue
+ }
+ if !info.Mode().IsRegular() {
+ continue
+ }
+ if info.Size() > maxScanFileBytes {
+ fmt.Fprintf(os.Stderr, "[ocr] WARNING: skipping %s (%d bytes exceeds %d-byte scan limit)\n",
+ rel, info.Size(), maxScanFileBytes)
+ continue
+ }
+ binary, err := isBinaryFile(full)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot sniff %s: %v\n", rel, err)
+ continue
+ }
+ if binary {
+ // Emit placeholder so preview can display [B], but do not
+ // read the file body — saves memory on large binaries.
+ out = append(out, model.ScanItem{
+ Path: rel,
+ IsBinary: true,
+ })
+ continue
+ }
+ content, err := os.ReadFile(full)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot read %s: %v\n", rel, err)
+ continue
+ }
+ out = append(out, model.ScanItem{
+ Path: rel,
+ Content: string(content),
+ IsBinary: false,
+ LineCount: countLines(content),
+ })
+ }
+ return out, nil
+}
+
+// listFiles returns all tracked + untracked (non-ignored) files in the repo.
+func (p *Provider) listFiles(ctx context.Context) ([]string, error) {
+ tracked, err := p.gitLs(ctx, "-z")
+ if err != nil {
+ return nil, fmt.Errorf("git ls-files (tracked): %w", err)
+ }
+ untracked, err := p.gitLs(ctx, "-z", "--others", "--exclude-standard")
+ if err != nil {
+ return nil, fmt.Errorf("git ls-files (untracked): %w", err)
+ }
+
+ seen := make(map[string]struct{}, len(tracked)+len(untracked))
+ all := make([]string, 0, len(tracked)+len(untracked))
+ for _, f := range append(tracked, untracked...) {
+ if f == "" {
+ continue
+ }
+ if _, ok := seen[f]; ok {
+ continue
+ }
+ seen[f] = struct{}{}
+ all = append(all, f)
+ }
+ return all, nil
+}
+
+func (p *Provider) gitLs(ctx context.Context, args ...string) ([]string, error) {
+ cmdArgs := append([]string{"-c", "core.quotepath=false", "ls-files"}, args...)
+ var out string
+ var err error
+ if p.runner != nil {
+ out, err = p.runner.Run(ctx, p.repoDir, cmdArgs...)
+ } else {
+ cmd := exec.CommandContext(ctx, "git", cmdArgs...)
+ cmd.Dir = p.repoDir
+ raw, runErr := cmd.CombinedOutput()
+ out, err = string(raw), runErr
+ }
+ if err != nil {
+ return nil, err
+ }
+ raw := strings.Split(strings.TrimRight(out, "\x00"), "\x00")
+ files := make([]string, 0, len(raw))
+ for _, f := range raw {
+ f = strings.TrimSpace(f)
+ if f != "" {
+ files = append(files, f)
+ }
+ }
+ return files, nil
+}
+
+// filterByPaths keeps only entries whose path equals a user-supplied path
+// (for exact files) or lies under it (for directories).
+func filterByPaths(all []string, paths []string) []string {
+ var out []string
+ for _, f := range all {
+ for _, want := range paths {
+ if f == want || strings.HasPrefix(f, want+"/") {
+ out = append(out, f)
+ break
+ }
+ }
+ }
+ return out
+}
+
+// countLines returns the number of lines in content. A file without a
+// trailing newline still counts its final line. Empty input → 0.
+func countLines(content []byte) int {
+ if len(content) == 0 {
+ return 0
+ }
+ n := bytes.Count(content, []byte{'\n'})
+ if content[len(content)-1] != '\n' {
+ n++
+ }
+ return n
+}
+
+// isBinaryFile reads up to binarySniffWindow bytes from path and reports
+// whether they contain a NUL byte (git's "binary" heuristic).
+func isBinaryFile(path string) (bool, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return false, err
+ }
+ defer f.Close()
+ buf := make([]byte, binarySniffWindow)
+ n, err := io.ReadFull(f, buf)
+ if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
+ return false, err
+ }
+ return bytes.IndexByte(buf[:n], 0) >= 0, nil
+}
diff --git a/internal/scan/provider_test.go b/internal/scan/provider_test.go
new file mode 100644
index 0000000..8cadbaf
--- /dev/null
+++ b/internal/scan/provider_test.go
@@ -0,0 +1,194 @@
+package scan
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "strings"
+ "testing"
+)
+
+func TestCountLines(t *testing.T) {
+ tests := []struct {
+ name string
+ in []byte
+ want int
+ }{
+ {"empty", []byte(""), 0},
+ {"single line no newline", []byte("foo"), 1},
+ {"single line trailing newline", []byte("foo\n"), 1},
+ {"two lines no trailing newline", []byte("foo\nbar"), 2},
+ {"two lines trailing newline", []byte("foo\nbar\n"), 2},
+ {"only newline", []byte("\n"), 1},
+ {"three lines mixed", []byte("a\n\nb"), 3},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := countLines(tt.in); got != tt.want {
+ t.Errorf("countLines(%q) = %d, want %d", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFilterByPaths(t *testing.T) {
+ all := []string{
+ "cmd/main.go",
+ "internal/agent/agent.go",
+ "internal/agent/fullscan.go",
+ "internal/scan/provider.go",
+ "README.md",
+ }
+ tests := []struct {
+ name string
+ paths []string
+ want []string
+ }{
+ {"exact file", []string{"README.md"}, []string{"README.md"}},
+ {"dir prefix", []string{"internal/agent"}, []string{"internal/agent/agent.go", "internal/agent/fullscan.go"}},
+ {"multi", []string{"cmd/main.go", "internal/scan"}, []string{"cmd/main.go", "internal/scan/provider.go"}},
+ {"prefix not at boundary", []string{"internal/age"}, nil},
+ {"no match", []string{"does/not/exist"}, nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := filterByPaths(all, tt.paths)
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("filterByPaths() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestNewProvider_NormalizesPaths(t *testing.T) {
+ p := NewProvider("/tmp/repo", []string{
+ " ",
+ "./internal/agent/",
+ "cmd",
+ " internal/diff ",
+ filepath.FromSlash("a/b"),
+ }, nil)
+ want := []string{"internal/agent", "cmd", "internal/diff", "a/b"}
+ if !reflect.DeepEqual(p.paths, want) {
+ t.Errorf("paths = %v, want %v", p.paths, want)
+ }
+}
+
+func initTestRepo(t *testing.T) string {
+ t.Helper()
+ repo := t.TempDir()
+ run := func(args ...string) {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repo
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
+ )
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ run("init", "-b", "main")
+ run("config", "user.email", "test@example.com")
+ run("config", "user.name", "test")
+ run("config", "commit.gpgsign", "false")
+ return repo
+}
+
+func writeFile(t *testing.T, root, rel string, content []byte) {
+ t.Helper()
+ full := filepath.Join(root, rel)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ if err := os.WriteFile(full, content, 0o644); err != nil {
+ t.Fatalf("write %s: %v", rel, err)
+ }
+}
+
+func gitCommit(t *testing.T, repo, msg string) {
+ t.Helper()
+ for _, args := range [][]string{{"add", "-A"}, {"commit", "-m", msg}} {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repo
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
+ )
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+}
+
+func TestProvider_Enumerate_FullRepo(t *testing.T) {
+ repo := initTestRepo(t)
+ writeFile(t, repo, "main.go", []byte("package main\n\nfunc main() {}\n"))
+ writeFile(t, repo, "pkg/util.go", []byte("package pkg\n"))
+ writeFile(t, repo, "image.bin", []byte{0x00, 0x01, 0x02})
+ writeFile(t, repo, ".gitignore", []byte("ignored.txt\n"))
+ writeFile(t, repo, "ignored.txt", []byte("should not appear\n"))
+ gitCommit(t, repo, "init")
+
+ got, err := NewProvider(repo, nil, nil).Enumerate(context.Background())
+ if err != nil {
+ t.Fatalf("Enumerate: %v", err)
+ }
+
+ paths := make([]string, 0, len(got))
+ for _, it := range got {
+ paths = append(paths, it.Path)
+ }
+ sort.Strings(paths)
+ want := []string{".gitignore", "image.bin", "main.go", "pkg/util.go"}
+ if !reflect.DeepEqual(paths, want) {
+ t.Errorf("paths = %v, want %v", paths, want)
+ }
+
+ for _, it := range got {
+ switch it.Path {
+ case "main.go":
+ if it.IsBinary {
+ t.Errorf("main.go must not be binary")
+ }
+ if !strings.Contains(it.Content, "package main") {
+ t.Errorf("main.go content unexpected: %q", it.Content)
+ }
+ if it.LineCount != 3 {
+ t.Errorf("main.go LineCount = %d, want 3", it.LineCount)
+ }
+ case "image.bin":
+ if !it.IsBinary {
+ t.Errorf("image.bin must be binary")
+ }
+ if it.Content != "" {
+ t.Errorf("binary item must not store content, got %d bytes", len(it.Content))
+ }
+ }
+ }
+}
+
+func TestProvider_Enumerate_PathFilter(t *testing.T) {
+ repo := initTestRepo(t)
+ writeFile(t, repo, "a.go", []byte("package a\n"))
+ writeFile(t, repo, "pkg/b.go", []byte("package pkg\n"))
+ writeFile(t, repo, "pkg/sub/c.go", []byte("package sub\n"))
+ gitCommit(t, repo, "init")
+
+ got, err := NewProvider(repo, []string{"pkg"}, nil).Enumerate(context.Background())
+ if err != nil {
+ t.Fatalf("Enumerate: %v", err)
+ }
+ paths := make([]string, 0, len(got))
+ for _, it := range got {
+ paths = append(paths, it.Path)
+ }
+ sort.Strings(paths)
+ want := []string{"pkg/b.go", "pkg/sub/c.go"}
+ if !reflect.DeepEqual(paths, want) {
+ t.Errorf("paths = %v, want %v", paths, want)
+ }
+}
diff --git a/internal/session/history.go b/internal/session/history.go
index eaf2444..6ece4da 100644
--- a/internal/session/history.go
+++ b/internal/session/history.go
@@ -27,6 +27,7 @@ const (
ReviewModeWorkspace = "workspace"
ReviewModeRange = "range"
ReviewModeCommit = "commit"
+ ReviewModeFullScan = "full_scan"
)
// SessionHistory is the top-level container for an entire CR run.