mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-08-20 06:04:11 +00:00
* feat: add tool_calls field to JSON output for tool usage statistics Track per-tool invocation counts in llmloop.Runner and expose them through the ResultProvider interface so both review and scan modes report tool call statistics in --format json output. * fix: correct tool_calls counting and ensure stable JSON schema Move recordToolCall after lookupTool nil check so only actually-executed tool calls are counted. Always emit tool_calls field in JSON output for a consistent schema, initializing by_tool to empty map when nil. * fix: remove omitempty from tool_calls to ensure stable JSON schema Drop omitempty from the tool_calls struct tag and initialize ToolCalls in outputJSONNoFiles so the field is always present in JSON output regardless of execution path.
810 lines
27 KiB
Go
810 lines
27 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
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 a.diffs {
|
|
if a.diffs[i].IsDeleted {
|
|
continue
|
|
}
|
|
dispatched++
|
|
wg.Add(1)
|
|
sem <- struct{}{} // acquire semaphore
|
|
|
|
go func(d model.Diff) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }() // release
|
|
|
|
var fileCtx context.Context
|
|
var cancel context.CancelFunc
|
|
if timeout > 0 {
|
|
fileCtx, cancel = context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
} else {
|
|
fileCtx = ctx
|
|
}
|
|
|
|
if err := a.executeSubtask(fileCtx, d); err != nil {
|
|
atomic.AddInt64(&a.subtaskFailed, 1)
|
|
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())
|
|
}
|
|
}(a.diffs[i])
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
if dispatched == 0 {
|
|
return []model.LlmComment{}, 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
|
|
}
|
|
|
|
// executeSubtask performs the Plan Phase + Main Loop for a single file.
|
|
func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) 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 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 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 nil
|
|
}
|
|
|
|
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()
|
|
}
|
|
a.executeReviewFilter(ctx, d, newPath)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// 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) {
|
|
ft := a.args.Template.ReviewFilterTask
|
|
if ft == nil || len(ft.Messages) == 0 {
|
|
return
|
|
}
|
|
|
|
comments := a.args.CommentCollector.CommentsForPath(newPath)
|
|
if len(comments) == 0 {
|
|
return
|
|
}
|
|
|
|
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()
|
|
|
|
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
|
|
Model: a.args.Model,
|
|
Messages: messages,
|
|
MaxTokens: a.args.Template.MaxTokens,
|
|
})
|
|
if err != nil {
|
|
rec.SetError(err, time.Since(startTime))
|
|
fmt.Fprintf(stdout.Writer(), "[ocr] Review filter failed for %s: %v\n", newPath, err)
|
|
return
|
|
}
|
|
rec.SetResponse(resp, time.Since(startTime))
|
|
a.runner.RecordUsage(resp.Usage)
|
|
|
|
indices := parseFilterResponse(resp.Content(), len(comments))
|
|
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) {
|
|
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()
|
|
|
|
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
|
|
Model: a.args.Model,
|
|
Messages: messages,
|
|
MaxTokens: a.args.Template.MaxTokens,
|
|
})
|
|
if err != nil {
|
|
rec.SetError(err, time.Since(startTime))
|
|
return "", fmt.Errorf("plan request: %w", err)
|
|
}
|
|
rec.SetResponse(resp, time.Since(startTime))
|
|
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
|
|
}
|