open-code-review/internal/agent/agent.go
MuoDoo f35437671c
feat(review): add resumable sessions and session inspection (#306)
* 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
2026-07-09 11:43:11 +08:00

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
}