mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-10 01:39:12 +00:00
* feat(review): add session resume support * Add session inspection command * fix(review): correct session resume checkpoint handling * fix(review): require completed main loop for resume checkpoints * docs: document review resume sessions * docs: refine resume session guidance
993 lines
33 KiB
Go
993 lines
33 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"runtime/debug"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"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/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"
|
|
|
|
"go.opentelemetry.io/otel/codes"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Args holds all dependencies and configuration needed to run a review session.
|
|
type Args struct {
|
|
// RepoDir is the root of the git repository.
|
|
RepoDir string
|
|
|
|
// From and To define the diff range (e.g., "main..feature-branch").
|
|
From string
|
|
To string
|
|
|
|
// Commit is a single commit hash to review (vs its parent).
|
|
Commit string
|
|
|
|
// 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.
|
|
Template template.Template
|
|
|
|
// SystemRule holds path-based review rules loaded from a JSON config.
|
|
SystemRule rules.Resolver
|
|
|
|
// FileFilter holds user-configured include/exclude path patterns from rule.json.
|
|
// When nil, only the default extension and path filters apply.
|
|
FileFilter *rules.FileFilter
|
|
|
|
// LLM client for model inference.
|
|
LLMClient llm.LLMClient
|
|
|
|
// Tool registry mapping tool aliases to implementations.
|
|
Tools *tool.Registry
|
|
|
|
// PlanToolDefs holds llm.ToolDef entries enabled in plan_task, built once at startup.
|
|
// When nil, plan phase sends no tool definitions (same as Java behavior when plan_task is false).
|
|
PlanToolDefs []llm.ToolDef
|
|
|
|
// MainToolDefs holds llm.ToolDef entries enabled in main_task, built once at startup.
|
|
MainToolDefs []llm.ToolDef
|
|
|
|
// CommentWorkerPool — separate goroutine pool for running asynchronous
|
|
// comment post-processing tasks (tracking, re-tracking, reflection,
|
|
// suggestion validation). This mirrors the Java side's subtaskExecutor
|
|
// which executes the CODE_COMMENT tool off the critical path so that the
|
|
// main LLM tool-use loop can continue issuing requests while comments are
|
|
// being processed in the background.
|
|
//
|
|
// When nil (the default), comment processing happens synchronously inside
|
|
// executeToolCall instead of via a separate worker pool.
|
|
CommentWorkerPool *CommentWorkerPool
|
|
|
|
// Concurrency limit for per-file subtasks. Defaults to number of CPUs.
|
|
MaxConcurrency int
|
|
|
|
// Concurrent task timeout in minutes. 0 means no timeout.
|
|
ConcurrentTaskTimeout int
|
|
|
|
// CommentCollector collects review comments generated by the code_comment tool.
|
|
CommentCollector *tool.CommentCollector
|
|
|
|
// Background is an optional requirement/business context string
|
|
// injected into plan and main_task prompts via {{requirement_background}}.
|
|
Background string
|
|
|
|
// Model is the user-configured model name used as fallback when
|
|
// template phases (plan/memory_compression) don't specify one.
|
|
Model string
|
|
|
|
// GitRunner limits the total number of concurrent git subprocesses.
|
|
// When nil, subprocesses are spawned without a global limit.
|
|
GitRunner *gitcmd.Runner
|
|
|
|
// Session is an optional session history instance for collecting conversation records.
|
|
// When nil, a default one is created automatically with git branch auto-detected from repoDir.
|
|
Session *session.SessionHistory
|
|
|
|
// Resume is an optional read-only checkpoint index from a previous review session.
|
|
Resume *session.ResumeState
|
|
}
|
|
|
|
// 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
|
|
subtaskFailed int64 // count of failed subtasks, accessed atomically
|
|
runner *llmloop.Runner
|
|
resumeInfo *ResumeInfo
|
|
}
|
|
|
|
// ResumeInfo summarizes file-level reuse for a resumed review.
|
|
type ResumeInfo struct {
|
|
ResumedFrom string `json:"resumed_from"`
|
|
ReusedFiles int64 `json:"reused_files"`
|
|
RerunFiles int64 `json:"rerun_files"`
|
|
PreviousModel string `json:"previous_model,omitempty"`
|
|
CurrentModel string `json:"current_model,omitempty"`
|
|
}
|
|
|
|
// New creates a new Agent from the given arguments.
|
|
func New(args Args) *Agent {
|
|
if args.Tools == nil {
|
|
args.Tools = tool.NewRegistry()
|
|
}
|
|
if args.CommentCollector == nil {
|
|
args.CommentCollector = tool.NewCommentCollector()
|
|
}
|
|
if args.Session == nil {
|
|
gitBranch := detectGitBranch(context.Background(), args.RepoDir)
|
|
mode := args.ReviewMode
|
|
if mode == "" {
|
|
mode = reviewModeString(args.From, args.To, args.Commit)
|
|
}
|
|
args.Session = session.New(args.RepoDir, gitBranch, args.Model, session.SessionOptions{
|
|
ReviewMode: mode,
|
|
DiffFrom: args.From,
|
|
DiffTo: args.To,
|
|
DiffCommit: args.Commit,
|
|
ResumedFrom: resumedFromSession(args.Resume),
|
|
})
|
|
}
|
|
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.
|
|
func (a *Agent) Run(ctx context.Context) ([]model.LlmComment, error) {
|
|
// Step 1: Parse diffs
|
|
ctx, diffSpan := telemetry.StartSpan(ctx, "diff.parse")
|
|
if err := a.loadDiffs(ctx); err != nil {
|
|
diffSpan.End()
|
|
return nil, fmt.Errorf("load diffs: %w", err)
|
|
}
|
|
telemetry.SetAttr(diffSpan, "files.changed", len(a.diffs))
|
|
telemetry.SetAttr(diffSpan, "lines.inserted", int64(a.totalInsertions))
|
|
telemetry.SetAttr(diffSpan, "lines.deleted", int64(a.totalDeletions))
|
|
diffSpan.End()
|
|
|
|
// Build the read-only DiffMap from ALL parsed diffs (before filtering)
|
|
// so the LLM can query diffs of related but filtered-out files.
|
|
a.injectDiffMap()
|
|
a.args.Tools.Freeze()
|
|
|
|
totalChanged := len(a.diffs)
|
|
reviewCount := a.countReviewable(a.diffs)
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] %d file(s) changed, reviewing %d in %s\n", totalChanged, reviewCount, a.args.RepoDir)
|
|
|
|
a.diffs = a.filterDiffs(a.diffs)
|
|
|
|
if len(a.diffs) == 0 {
|
|
fmt.Fprintln(stdout.Writer(), "[ocr] No supported files changed. Skipping review.")
|
|
telemetry.Event(ctx, "no.files.changed")
|
|
a.session.Finalize()
|
|
return []model.LlmComment{}, nil
|
|
}
|
|
|
|
a.currentDate = time.Now().Format("2006-01-02 15:04")
|
|
telemetry.Event(ctx, "review.started",
|
|
telemetry.AnyToAttr("file.count", totalChanged),
|
|
telemetry.AnyToAttr("review.count", reviewCount),
|
|
telemetry.AnyToAttr("repo.dir", a.args.RepoDir))
|
|
|
|
// Record file count metric.
|
|
telemetry.RecordFilesReviewed(ctx, int64(reviewCount))
|
|
|
|
// Step 2: Dispatch per-file subtasks concurrently
|
|
comments, err := a.dispatchSubtasks(ctx)
|
|
if len(comments) > 0 {
|
|
telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
|
|
}
|
|
a.session.Finalize()
|
|
return comments, err
|
|
}
|
|
|
|
// Session returns the session history associated with this Agent.
|
|
func (a *Agent) Session() *session.SessionHistory {
|
|
return a.session
|
|
}
|
|
|
|
// SessionID returns the current review's session id, or "" when no session has been created.
|
|
func (a *Agent) SessionID() string {
|
|
if a == nil || a.session == nil {
|
|
return ""
|
|
}
|
|
return a.session.SessionID
|
|
}
|
|
|
|
// ResumeInfo returns resume metadata for output. Nil means this was not a resume run.
|
|
func (a *Agent) ResumeInfo() *ResumeInfo {
|
|
if a.resumeInfo == nil {
|
|
return nil
|
|
}
|
|
info := *a.resumeInfo
|
|
return &info
|
|
}
|
|
|
|
// FilesReviewed returns the number of changed files included in this review.
|
|
func (a *Agent) FilesReviewed() int64 {
|
|
return int64(len(a.diffs))
|
|
}
|
|
|
|
// Diffs returns the parsed diffs loaded by the agent.
|
|
func (a *Agent) Diffs() []model.Diff {
|
|
return a.diffs
|
|
}
|
|
|
|
// TotalTokensUsed returns PromptTokens + CompletionTokens across all LLM calls.
|
|
// For Anthropic, PromptTokens already includes cache read/write tokens.
|
|
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 a.runner.TotalInputTokens() }
|
|
|
|
// TotalOutputTokens returns the accumulated completion tokens from all LLM calls.
|
|
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 a.runner.TotalCacheReadTokens() }
|
|
|
|
// TotalCacheWriteTokens returns the accumulated cache write tokens from all LLM calls.
|
|
func (a *Agent) TotalCacheWriteTokens() int64 { return a.runner.TotalCacheWriteTokens() }
|
|
|
|
// ProjectSummary returns the markdown project-level summary. Always empty
|
|
// for the diff-review path; defined so *Agent satisfies the
|
|
// cmd/opencodereview.ResultProvider interface that scan.Agent also implements.
|
|
func (a *Agent) ProjectSummary() string { return "" }
|
|
|
|
// Warnings returns a copy of non-fatal warnings recorded during review.
|
|
func (a *Agent) Warnings() []AgentWarning { return a.runner.Warnings() }
|
|
|
|
// ToolCalls returns per-tool call counts accumulated during review.
|
|
func (a *Agent) ToolCalls() map[string]int64 { return a.runner.ToolCalls() }
|
|
|
|
// recordWarning adds a non-fatal warning to the agent's warning list.
|
|
func (a *Agent) recordWarning(warningType, file, message string) {
|
|
a.runner.RecordWarning(warningType, file, message)
|
|
}
|
|
|
|
// loadDiffs populates the diff-related fields.
|
|
func (a *Agent) loadDiffs(ctx context.Context) error {
|
|
var provider *diff.Provider
|
|
|
|
switch {
|
|
case a.args.Commit != "":
|
|
provider = diff.NewCommitProvider(a.args.RepoDir, a.args.Commit, a.args.GitRunner)
|
|
case a.args.From != "" && a.args.To != "":
|
|
provider = diff.NewProvider(a.args.RepoDir, a.args.From, a.args.To, a.args.GitRunner)
|
|
default:
|
|
provider = diff.NewWorkspaceProvider(a.args.RepoDir, a.args.GitRunner)
|
|
}
|
|
|
|
parsed, err := provider.GetDiff(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("get diffs: %w", err)
|
|
}
|
|
|
|
a.diffs = parsed
|
|
|
|
for i := range parsed {
|
|
d := &parsed[i]
|
|
a.totalInsertions += d.Insertions
|
|
a.totalDeletions += d.Deletions
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// injectDiffMap builds a read-only DiffMap from parsed diffs and sets it
|
|
// on the FileReadDiffProvider. Must be called after loadDiffs and before
|
|
// any concurrent access to the registry.
|
|
func (a *Agent) injectDiffMap() {
|
|
m := make(map[string]string, len(a.diffs))
|
|
for i := range a.diffs {
|
|
d := &a.diffs[i]
|
|
if d.NewPath != "/dev/null" {
|
|
m[d.NewPath] = d.Diff
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// dispatchSubtasks runs the Plan + Main phases for each changed file concurrently.
|
|
func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error) {
|
|
startTime := time.Now()
|
|
defer func() {
|
|
telemetry.RecordReviewDuration(ctx, time.Since(startTime))
|
|
}()
|
|
|
|
// Pre-filter: discard diffs whose diff content alone exceeds 80% of the token threshold.
|
|
a.diffs = a.filterLargeDiffs(a.diffs)
|
|
if len(a.diffs) == 0 {
|
|
return nil, fmt.Errorf("all diffs filtered out by token size")
|
|
}
|
|
toDispatch := a.applyResume(a.diffs)
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
concurrency := a.args.MaxConcurrency
|
|
if concurrency <= 0 {
|
|
concurrency = 8
|
|
}
|
|
|
|
sem := make(chan struct{}, concurrency)
|
|
timeout := time.Duration(a.args.ConcurrentTaskTimeout) * time.Minute
|
|
|
|
var dispatched int64
|
|
for i := range toDispatch {
|
|
if toDispatch[i].IsDeleted {
|
|
continue
|
|
}
|
|
dispatched++
|
|
wg.Add(1)
|
|
sem <- struct{}{} // acquire semaphore
|
|
|
|
go func(d model.Diff) {
|
|
fingerprint := reviewItemFingerprint(a.reviewMode(), d)
|
|
defer wg.Done()
|
|
defer func() { <-sem }() // release
|
|
// A panic while reviewing one file must be isolated exactly like an
|
|
// error return: counted in subtaskFailed and recorded as a
|
|
// subtask_error warning, so other files still complete and the
|
|
// all-failed rollup below stays correct. Registered before the
|
|
// timeout-cancel defer, so cancel() still runs first on unwind and
|
|
// fileCtx is already cancelled here — use the parent ctx for telemetry.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
atomic.AddInt64(&a.subtaskFailed, 1)
|
|
a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, fmt.Sprintf("panic: %v", r))
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Subtask panic for %s: %v\n%s\n", d.NewPath, r, debug.Stack())
|
|
telemetry.ErrorEvent(ctx, "subtask.panic", fmt.Errorf("panic: %v", r),
|
|
telemetry.AnyToAttr("file.path", d.NewPath))
|
|
a.recordWarning("subtask_error", d.NewPath, fmt.Sprintf("panic: %v", r))
|
|
}
|
|
}()
|
|
|
|
var fileCtx context.Context
|
|
var cancel context.CancelFunc
|
|
if timeout > 0 {
|
|
fileCtx, cancel = context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
} else {
|
|
fileCtx = ctx
|
|
}
|
|
|
|
completed, skipReason, err := a.executeSubtask(fileCtx, d)
|
|
if err != nil {
|
|
atomic.AddInt64(&a.subtaskFailed, 1)
|
|
a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, err.Error())
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Subtask error for %s: %v\n", d.NewPath, err)
|
|
telemetry.ErrorEvent(fileCtx, "subtask.error", err,
|
|
telemetry.AnyToAttr("file.path", d.NewPath))
|
|
a.recordWarning("subtask_error", d.NewPath, err.Error())
|
|
return
|
|
}
|
|
if !completed {
|
|
if skipReason != "" {
|
|
a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, skipReason)
|
|
}
|
|
return
|
|
}
|
|
comments := a.args.CommentCollector.CommentsForPath(d.NewPath)
|
|
a.session.RecordReviewItemDone(d.NewPath, d.OldPath, d.NewPath, fingerprint, comments)
|
|
}(toDispatch[i])
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
if dispatched == 0 {
|
|
return a.args.CommentCollector.Comments(), nil
|
|
}
|
|
|
|
// All subtasks finished — collect comments from the global collector once.
|
|
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 review(s) failed — check your LLM configuration and API key", dispatched)
|
|
}
|
|
|
|
return a.args.CommentCollector.Comments(), nil
|
|
}
|
|
|
|
func (a *Agent) applyResume(diffs []model.Diff) []model.Diff {
|
|
resume := a.args.Resume
|
|
if resume == nil {
|
|
return diffs
|
|
}
|
|
|
|
mode := a.reviewMode()
|
|
toDispatch := make([]model.Diff, 0, len(diffs))
|
|
var reused int64
|
|
for _, d := range diffs {
|
|
if d.IsDeleted {
|
|
toDispatch = append(toDispatch, d)
|
|
continue
|
|
}
|
|
fingerprint := reviewItemFingerprint(mode, d)
|
|
item, ok := resume.Item(fingerprint)
|
|
if !ok {
|
|
toDispatch = append(toDispatch, d)
|
|
continue
|
|
}
|
|
for _, cm := range item.Comments {
|
|
a.args.CommentCollector.Add(cm)
|
|
}
|
|
a.session.RecordReviewItemReused(effectivePath(d), d.OldPath, d.NewPath, fingerprint, resume.SessionID, item.Comments)
|
|
reused++
|
|
}
|
|
|
|
rerun := countDispatchable(toDispatch)
|
|
a.resumeInfo = &ResumeInfo{
|
|
ResumedFrom: resume.SessionID,
|
|
ReusedFiles: reused,
|
|
RerunFiles: rerun,
|
|
PreviousModel: resume.Model,
|
|
CurrentModel: a.args.Model,
|
|
}
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Resume %s: reusing %d file(s), reviewing %d file(s)\n", resume.SessionID, reused, rerun)
|
|
return toDispatch
|
|
}
|
|
|
|
func countDispatchable(diffs []model.Diff) int64 {
|
|
var n int64
|
|
for _, d := range diffs {
|
|
if !d.IsDeleted {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (a *Agent) reviewMode() string {
|
|
if a.args.ReviewMode != "" {
|
|
return a.args.ReviewMode
|
|
}
|
|
return reviewModeString(a.args.From, a.args.To, a.args.Commit)
|
|
}
|
|
|
|
func reviewItemFingerprint(mode string, d model.Diff) string {
|
|
sum := sha256.Sum256([]byte(mode + "\x00" + d.OldPath + "\x00" + d.NewPath + "\x00" + d.Diff))
|
|
return fmt.Sprintf("%x", sum)
|
|
}
|
|
|
|
func resumedFromSession(resume *session.ResumeState) string {
|
|
if resume == nil {
|
|
return ""
|
|
}
|
|
return resume.SessionID
|
|
}
|
|
|
|
// executeSubtask performs the Plan Phase + Main Loop for a single file.
|
|
func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) (bool, string, error) {
|
|
ctx, span := telemetry.StartSpan(ctx, "subtask.execute."+d.NewPath)
|
|
defer span.End()
|
|
telemetry.SetAttr(span, "file.path", d.NewPath)
|
|
telemetry.SetAttr(span, "lines.changed", d.Insertions+d.Deletions)
|
|
telemetry.SetAttr(span, "lines.inserted", d.Insertions)
|
|
telemetry.SetAttr(span, "lines.deleted", d.Deletions)
|
|
|
|
if ctx.Err() != nil {
|
|
return false, "", ctx.Err()
|
|
}
|
|
|
|
newPath := d.NewPath
|
|
|
|
// Build change-files list excluding current file
|
|
changeFilesExcludingCurrent := a.buildChangeFilesExcept(newPath)
|
|
|
|
rule := a.resolveSystemRule(strings.ToLower(newPath))
|
|
|
|
threshold := a.args.Template.PlanModeLineThreshold
|
|
changeLines := d.Insertions + d.Deletions
|
|
|
|
// Phase 1: Plan (skip when changes are below threshold)
|
|
var planResult string
|
|
if a.args.Template.PlanTask != nil && len(a.args.Template.PlanTask.Messages) > 0 && threshold > 0 && changeLines < int64(threshold) {
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Skipping plan phase for %s (%d lines < threshold %d)\n", newPath, changeLines, threshold)
|
|
telemetry.Event(ctx, "plan.skipped",
|
|
telemetry.AnyToAttr("file.path", newPath),
|
|
telemetry.AnyToAttr("lines.changed", changeLines),
|
|
telemetry.AnyToAttr("threshold", threshold))
|
|
} else if a.args.Template.PlanTask != nil && len(a.args.Template.PlanTask.Messages) > 0 {
|
|
var err error
|
|
planResult, err = a.executePlanPhase(ctx, newPath, d.Diff, changeFilesExcludingCurrent, rule)
|
|
if err != nil {
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Plan phase failed for %s: %v (continuing without plan)\n", newPath, err)
|
|
telemetry.Eventf(ctx, "plan.failed", err.Error(),
|
|
telemetry.AnyToAttr("file.path", newPath))
|
|
planResult = ""
|
|
}
|
|
}
|
|
|
|
// Phase 2: Main task loop
|
|
if len(a.args.Template.MainTask.Messages) == 0 {
|
|
return false, "", fmt.Errorf("main_task.messages is empty in template")
|
|
}
|
|
|
|
rawMsgs := a.args.Template.MainTask.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}}", newPath)
|
|
content = strings.ReplaceAll(content, "{{system_rule}}", rule)
|
|
content = strings.ReplaceAll(content, "{{change_files}}", changeFilesExcludingCurrent)
|
|
content = strings.ReplaceAll(content, "{{diff}}", d.Diff)
|
|
content = strings.ReplaceAll(content, "{{requirement_background}}", a.args.Background)
|
|
// Always substitute the {{plan_guidance}} token so the literal placeholder
|
|
// never leaks into the rendered prompt. When the plan phase produced no
|
|
// output, strip the surrounding "### Review Plan (Optional)\n…\n\n" wrapper
|
|
// (any language variant) so the LLM does not see a dangling section header.
|
|
// Strip MUST run before ReplaceAll: the regex requires the literal
|
|
// {{plan_guidance}} token to be present; if we replace first, the token
|
|
// is gone and the wrapper can't be matched.
|
|
if planResult == "" {
|
|
content = stripEmptyPlanBlock(content)
|
|
}
|
|
content = strings.ReplaceAll(content, "{{plan_guidance}}", planResult)
|
|
messages = append(messages, llm.NewTextMessage(m.Role, content))
|
|
}
|
|
|
|
tokenCount := llmloop.CountMessagesTokens(messages)
|
|
maxAllowed := a.args.Template.MaxTokens
|
|
tokenLimit := maxAllowed * 4 / 5 // 80% of MaxTokens
|
|
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, newPath)
|
|
a.recordWarning("token_threshold_exceeded", newPath, msg)
|
|
telemetry.Event(ctx, "token.threshold.exceeded",
|
|
telemetry.AnyToAttr("file.path", newPath),
|
|
telemetry.AnyToAttr("tokens", tokenCount),
|
|
telemetry.AnyToAttr("max_tokens", maxAllowed))
|
|
return false, msg, nil
|
|
}
|
|
|
|
mainCompleted, err := func() (bool, error) {
|
|
ctx, mainSpan := telemetry.StartSpan(ctx, "main.loop")
|
|
defer mainSpan.End()
|
|
telemetry.SetAttr(mainSpan, "file.path", newPath)
|
|
completed, err := a.runner.RunPerFile(ctx, messages, newPath)
|
|
if err != nil {
|
|
mainSpan.SetStatus(codes.Error, err.Error())
|
|
mainSpan.RecordError(err)
|
|
return false, err
|
|
}
|
|
return completed, nil
|
|
}()
|
|
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()
|
|
}
|
|
a.executeReviewFilter(ctx, d, newPath)
|
|
}
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
if !mainCompleted {
|
|
return false, "main_task did not complete before stopping", nil
|
|
}
|
|
return true, "", nil
|
|
}
|
|
|
|
// executeReviewFilter runs the REVIEW_FILTER_TASK to remove comments that are
|
|
// provably incorrect based solely on the diff. Errors are logged and silently ignored.
|
|
func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath string) {
|
|
ctx, span := telemetry.StartSpan(ctx, "review_filter.execute")
|
|
defer span.End()
|
|
telemetry.SetAttr(span, "file.path", newPath)
|
|
|
|
ft := a.args.Template.ReviewFilterTask
|
|
if ft == nil || len(ft.Messages) == 0 {
|
|
return
|
|
}
|
|
|
|
comments := a.args.CommentCollector.CommentsForPath(newPath)
|
|
if len(comments) == 0 {
|
|
return
|
|
}
|
|
telemetry.SetAttr(span, "comments.before", len(comments))
|
|
|
|
commentsJSON := buildFilterCommentsJSON(comments)
|
|
|
|
messages := make([]llm.Message, 0, len(ft.Messages))
|
|
for _, m := range ft.Messages {
|
|
content := m.Content
|
|
content = strings.ReplaceAll(content, "{{path}}", newPath)
|
|
content = strings.ReplaceAll(content, "{{diff}}", d.Diff)
|
|
content = strings.ReplaceAll(content, "{{comments}}", commentsJSON)
|
|
messages = append(messages, llm.NewTextMessage(m.Role, content))
|
|
}
|
|
|
|
if ft.Timeout > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, time.Duration(ft.Timeout)*time.Second)
|
|
defer cancel()
|
|
}
|
|
|
|
fs := a.session.GetOrCreateFileSession(newPath)
|
|
rec := fs.AppendTaskRecord(session.ReviewFilterTask, messages)
|
|
startTime := time.Now()
|
|
|
|
_, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model)
|
|
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
|
|
Model: a.args.Model,
|
|
Messages: messages,
|
|
MaxTokens: a.args.Template.MaxTokens,
|
|
})
|
|
duration := time.Since(startTime)
|
|
if err != nil {
|
|
telemetry.RecordLLMResult(llmSpan, duration, 0, err)
|
|
llmSpan.End()
|
|
rec.SetError(err, duration)
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Review filter failed for %s: %v\n", newPath, err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
span.RecordError(err)
|
|
return
|
|
}
|
|
var totalTokens int64
|
|
if resp.Usage != nil {
|
|
totalTokens = resp.Usage.TotalTokens
|
|
}
|
|
telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
|
|
llmSpan.End()
|
|
rec.SetResponse(resp, duration)
|
|
a.runner.RecordUsage(resp.Usage)
|
|
|
|
indices := parseFilterResponse(resp.Content(), len(comments))
|
|
telemetry.SetAttr(span, "comments.filtered", len(indices))
|
|
if len(indices) == 0 {
|
|
return
|
|
}
|
|
|
|
a.args.CommentCollector.RemoveByPathAndIndices(newPath, indices)
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Review filter removed %d comment(s) for %s\n", len(indices), newPath)
|
|
}
|
|
|
|
// buildFilterCommentsJSON serializes comments into a JSON array with generated IDs.
|
|
func buildFilterCommentsJSON(comments []model.LlmComment) string {
|
|
type filterComment struct {
|
|
ID string `json:"id"`
|
|
Content string `json:"content"`
|
|
ExistingCode string `json:"existing_code,omitempty"`
|
|
}
|
|
items := make([]filterComment, len(comments))
|
|
for i, cm := range comments {
|
|
items[i] = filterComment{
|
|
ID: fmt.Sprintf("c-%d", i),
|
|
Content: cm.Content,
|
|
ExistingCode: cm.ExistingCode,
|
|
}
|
|
}
|
|
data, _ := json.Marshal(items)
|
|
return string(data)
|
|
}
|
|
|
|
// 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 = llmloop.StripMarkdownFences(raw)
|
|
var ids []string
|
|
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
|
|
preview := raw
|
|
if len(preview) > 200 {
|
|
preview = preview[:200] + "..."
|
|
}
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Review filter: failed to parse LLM response: %v, raw: %s\n", err, preview)
|
|
return nil
|
|
}
|
|
indices := make(map[int]struct{})
|
|
for _, id := range ids {
|
|
var idx int
|
|
if _, err := fmt.Sscanf(id, "c-%d", &idx); err == nil && idx >= 0 && idx < total {
|
|
indices[idx] = struct{}{}
|
|
}
|
|
}
|
|
return indices
|
|
}
|
|
|
|
// buildChangeFilesExcept returns a formatted list of changed files except the given path.
|
|
func (a *Agent) buildChangeFilesExcept(excludePath string) string {
|
|
var sb strings.Builder
|
|
for i, d := range a.diffs {
|
|
if d.IsBinary {
|
|
continue
|
|
}
|
|
if d.NewPath == excludePath || d.OldPath == excludePath {
|
|
continue
|
|
}
|
|
status := "MODIFIED"
|
|
switch {
|
|
case d.IsNew:
|
|
status = "ADDED"
|
|
case d.IsDeleted:
|
|
status = "DELETED"
|
|
case d.OldPath != d.NewPath:
|
|
status = "RENAMED"
|
|
}
|
|
sb.WriteString(status + " " + d.NewPath)
|
|
if i < len(a.diffs)-1 {
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// resolveSystemRule returns the rule text for a given file path,
|
|
// matching against PathRuleMap glob patterns, falling back to DefaultRule.
|
|
func (a *Agent) resolveSystemRule(path string) string {
|
|
if a.args.SystemRule == nil {
|
|
return ""
|
|
}
|
|
return a.args.SystemRule.Resolve(path)
|
|
}
|
|
|
|
// filterLargeDiffs drops diffs whose diff content alone consumes more than 80% of MaxTokens.
|
|
func (a *Agent) filterLargeDiffs(diffs []model.Diff) []model.Diff {
|
|
limit := a.args.Template.MaxTokens * 4 / 5
|
|
if limit <= 0 {
|
|
return diffs
|
|
}
|
|
var kept []model.Diff
|
|
skipped := 0
|
|
|
|
for _, d := range diffs {
|
|
tokens := llm.CountTokens(d.Diff)
|
|
if tokens > limit {
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s (~%d tokens exceeds 80%% of max_tokens(%d))\n",
|
|
d.NewPath, tokens, a.args.Template.MaxTokens)
|
|
skipped++
|
|
continue
|
|
}
|
|
kept = append(kept, d)
|
|
}
|
|
|
|
if skipped > 0 {
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Pre-filtered %d file(s) exceeding 80%% of max_tokens\n", skipped)
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// countReviewable counts diffs that will survive all filters and are not pure deletions.
|
|
func (a *Agent) countReviewable(diffs []model.Diff) int {
|
|
count := 0
|
|
for _, d := range diffs {
|
|
if !a.shouldReview(d) {
|
|
continue
|
|
}
|
|
if d.IsDeleted {
|
|
continue
|
|
}
|
|
count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
// shouldReview applies the filter algorithm via whyExcluded.
|
|
func (a *Agent) shouldReview(d model.Diff) bool {
|
|
return a.whyExcluded(d) == ExcludeNone
|
|
}
|
|
|
|
// filterDiffs drops diffs that should not be reviewed based on user-configured
|
|
// include/exclude patterns and default extension/path filters.
|
|
func (a *Agent) filterDiffs(diffs []model.Diff) []model.Diff {
|
|
var kept []model.Diff
|
|
skipped := 0
|
|
|
|
for _, d := range diffs {
|
|
path := effectivePath(d)
|
|
if !a.shouldReview(d) {
|
|
if d.IsBinary {
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s — binary file\n", path)
|
|
} else {
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s — filtered by path/extension rules\n", path)
|
|
}
|
|
skipped++
|
|
continue
|
|
}
|
|
kept = append(kept, d)
|
|
}
|
|
|
|
if skipped > 0 {
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Filtered %d file(s) by include/exclude rules\n", skipped)
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// extFromPath returns the file extension with leading dot, lowercased.
|
|
func (a *Agent) 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:])
|
|
}
|
|
|
|
// executePlanPhase runs the plan task for a single file, sending template messages
|
|
// with resolved placeholders and collecting the LLM response as plan guidance.
|
|
func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFiles, rule string) (string, error) {
|
|
ctx, span := telemetry.StartSpan(ctx, "plan.execute")
|
|
defer span.End()
|
|
telemetry.SetAttr(span, "file.path", newPath)
|
|
|
|
pt := a.args.Template.PlanTask
|
|
messages := make([]llm.Message, 0, len(pt.Messages))
|
|
for _, m := range pt.Messages {
|
|
content := m.Content
|
|
content = strings.ReplaceAll(content, "{{current_system_date_time}}", a.currentDate)
|
|
content = strings.ReplaceAll(content, "{{current_file_path}}", newPath)
|
|
content = strings.ReplaceAll(content, "{{system_rule}}", rule)
|
|
content = strings.ReplaceAll(content, "{{change_files}}", changeFiles)
|
|
content = strings.ReplaceAll(content, "{{diff}}", rawDiff)
|
|
content = strings.ReplaceAll(content, "{{requirement_background}}", a.args.Background)
|
|
content = strings.ReplaceAll(content, "{{plan_tools}}", formatToolDefs(a.args.PlanToolDefs))
|
|
messages = append(messages, llm.NewTextMessage(m.Role, content))
|
|
}
|
|
|
|
fs := a.session.GetOrCreateFileSession(newPath)
|
|
rec := fs.AppendTaskRecord(session.PlanTask, messages)
|
|
startTime := time.Now()
|
|
|
|
_, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model)
|
|
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
|
|
Model: a.args.Model,
|
|
Messages: messages,
|
|
MaxTokens: a.args.Template.MaxTokens,
|
|
})
|
|
duration := time.Since(startTime)
|
|
if err != nil {
|
|
telemetry.RecordLLMResult(llmSpan, duration, 0, err)
|
|
llmSpan.End()
|
|
rec.SetError(err, duration)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
span.RecordError(err)
|
|
return "", fmt.Errorf("plan request: %w", err)
|
|
}
|
|
var totalTokens int64
|
|
if resp.Usage != nil {
|
|
totalTokens = resp.Usage.TotalTokens
|
|
}
|
|
telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
|
|
llmSpan.End()
|
|
rec.SetResponse(resp, duration)
|
|
a.runner.RecordUsage(resp.Usage)
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Plan completed for %s\n", newPath)
|
|
return resp.Content(), nil
|
|
}
|
|
|
|
// formatToolDefs renders tool definitions as human-readable text for embedding in prompts.
|
|
func formatToolDefs(toolDefs []llm.ToolDef) string {
|
|
if len(toolDefs) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("### Available Tools (reference only — do not call)\n")
|
|
for _, td := range toolDefs {
|
|
fn := &td.Function
|
|
sb.WriteString(fmt.Sprintf("- **%s**: %s\n", fn.Name, fn.Description))
|
|
if params, ok := fn.Parameters["properties"].(map[string]any); ok && len(params) > 0 {
|
|
sb.WriteString(" Parameters:\n")
|
|
required := make(map[string]bool)
|
|
if reqList, ok := fn.Parameters["required"].([]any); ok {
|
|
for _, r := range reqList {
|
|
if s, ok := r.(string); ok {
|
|
required[s] = true
|
|
}
|
|
}
|
|
}
|
|
for name, p := range params {
|
|
suffix := ""
|
|
if required[name] {
|
|
suffix = " (required)"
|
|
}
|
|
if pm, ok := p.(map[string]any); ok {
|
|
desc, _ := pm["description"].(string)
|
|
sb.WriteString(fmt.Sprintf(" - %s: %s%s\n", name, desc, suffix))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf(" - %s%s\n", name, suffix))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// findDiff returns the Diff for the given file path, or nil if not found.
|
|
func (a *Agent) findDiff(path string) *model.Diff {
|
|
for i := range a.diffs {
|
|
if a.diffs[i].NewPath == path || a.diffs[i].OldPath == path {
|
|
return &a.diffs[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BuildToolDefs converts toolsconfig.ToolConfigEntry slice into []llm.ToolDef,
|
|
// filtering by phase (planOnly=true for plan_task, false for main_task).
|
|
func BuildToolDefs(entries []toolsconfig.ToolConfigEntry, planOnly bool) []llm.ToolDef {
|
|
var defs []llm.ToolDef
|
|
for _, e := range entries {
|
|
defRaw, ok := e.ToolDefsByPhase(planOnly)
|
|
if !ok {
|
|
continue
|
|
}
|
|
var fn llm.FunctionDef
|
|
if err := json.Unmarshal(defRaw, &fn); err != nil {
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] WARNING: failed to parse tool definition %q: %v\n", e.Name, err)
|
|
continue
|
|
}
|
|
defs = append(defs, llm.ToolDef{
|
|
Type: "function",
|
|
Function: fn,
|
|
})
|
|
}
|
|
return defs
|
|
}
|