feat: --format json 模式下静默进度输出,保持 stdout 纯净

This commit is contained in:
kite 2026-05-06 12:44:29 +08:00
parent 39d02d1a04
commit 5aea3ac1ed
5 changed files with 76 additions and 24 deletions

View file

@ -13,6 +13,7 @@ import (
"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/llm"
"github.com/open-code-review/open-code-review/internal/stdout"
"github.com/open-code-review/open-code-review/internal/telemetry"
"github.com/open-code-review/open-code-review/internal/tool"
)
@ -107,6 +108,11 @@ func runReview(args []string) error {
Model: model,
})
// Silence progress output in JSON mode so stdout stays clean.
if opts.outputFormat == "json" {
defer stdout.Quiet()()
}
ctx, span := telemetry.StartSpan(context.Background(), "review.run")
defer span.End()
startTime := time.Now()
@ -126,6 +132,7 @@ func runReview(args []string) error {
if len(comments) > 0 {
telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
}
telemetry.PrintTraceSummary(ag.FilesReviewed(), int64(len(comments)), ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(), duration)
if opts.outputFormat == "json" {

View file

@ -18,6 +18,7 @@ import (
"github.com/open-code-review/open-code-review/internal/llm"
"github.com/open-code-review/open-code-review/internal/model"
"github.com/open-code-review/open-code-review/internal/session"
"github.com/open-code-review/open-code-review/internal/stdout"
"github.com/open-code-review/open-code-review/internal/telemetry"
"github.com/open-code-review/open-code-review/internal/tool"
)
@ -137,7 +138,7 @@ func (p *CommentWorkerPool) Submit(f func() ([]model.LlmComment, error)) {
comments, err := f()
if err != nil {
fmt.Printf("[ocr] CommentWorkerPool error: %v\n", err)
fmt.Fprintf(stdout.Writer(), "[ocr] CommentWorkerPool error: %v\n", err)
}
p.resultsMu.Lock()
p.results = append(p.results, comments...)
@ -185,7 +186,7 @@ func (a *Agent) Run(ctx context.Context) ([]model.LlmComment, error) {
a.diffs = a.filterUnsupportedExts(a.diffs)
if len(a.diffs) == 0 {
fmt.Println("[ocr] No supported files changed. Skipping review.")
fmt.Fprintln(stdout.Writer(), "[ocr] No supported files changed. Skipping review.")
telemetry.Event(ctx, "no.files.changed")
a.session.Finalize()
return nil, nil
@ -193,7 +194,7 @@ func (a *Agent) Run(ctx context.Context) ([]model.LlmComment, error) {
a.currentDate = time.Now().Format("2006-01-02 15:04")
fmt.Printf("[ocr] Reviewing %d file(s) in %s\n", len(a.diffs), a.args.RepoDir)
fmt.Fprintf(stdout.Writer(), "[ocr] Reviewing %d file(s) in %s\n", len(a.diffs), a.args.RepoDir)
telemetry.Event(ctx, "review.started",
telemetry.AnyToAttr("file.count", len(a.diffs)),
telemetry.AnyToAttr("repo.dir", a.args.RepoDir))
@ -323,7 +324,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
}
if err := a.executeSubtask(fileCtx, d); err != nil {
fmt.Printf("[ocr] Subtask error for %s: %v\n", d.NewPath, err)
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))
}
@ -365,7 +366,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
// 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.Printf("[ocr] Skipping plan phase for %s (%d lines < threshold %d)\n", newPath, changeLines, 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),
@ -374,7 +375,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
var err error
planResult, err = a.executePlanPhase(ctx, newPath, d.Diff, changeFilesExcludingCurrent, rule)
if err != nil {
fmt.Printf("[ocr] Plan phase failed for %s: %v (continuing without plan)\n", newPath, err)
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 = ""
@ -405,7 +406,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
tokenCount := countMessagesTokens(messages)
if tokenCount > a.args.Template.TokenWarningThreshold {
fmt.Printf("[ocr] WARNING: prompt tokens (%d) exceed threshold (%d) for %s\n",
fmt.Fprintf(stdout.Writer(), "[ocr] WARNING: prompt tokens (%d) exceed threshold (%d) for %s\n",
tokenCount, a.args.Template.TokenWarningThreshold, newPath)
telemetry.Event(ctx, "token.threshold.exceeded",
telemetry.AnyToAttr("file.path", newPath),
@ -468,7 +469,7 @@ func (a *Agent) filterLargeDiffs(diffs []model.Diff) []model.Diff {
for _, d := range diffs {
tokens := llm.CountTokens(d.Diff)
if float64(tokens) > limit {
fmt.Printf("[ocr] Skipping %s (~%d tokens exceeds 80%% of threshold %d)\n",
fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s (~%d tokens exceeds 80%% of threshold %d)\n",
d.NewPath, tokens, threshold)
skipped++
continue
@ -477,7 +478,7 @@ func (a *Agent) filterLargeDiffs(diffs []model.Diff) []model.Diff {
}
if skipped > 0 {
fmt.Printf("[ocr] Pre-filtered %d file(s) exceeding 80%% token threshold\n", skipped)
fmt.Fprintf(stdout.Writer(), "[ocr] Pre-filtered %d file(s) exceeding 80%% token threshold\n", skipped)
}
return kept
}
@ -494,7 +495,7 @@ func (a *Agent) filterUnsupportedExts(diffs []model.Diff) []model.Diff {
}
ext := a.extFromPath(path)
if ext != "" && !allowedext.IsAllowedExt(ext) {
fmt.Printf("[ocr] Skipping %s — extension %q not in supported file types\n", d.NewPath, ext)
fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s — extension %q not in supported file types\n", d.NewPath, ext)
skipped++
continue
}
@ -502,7 +503,7 @@ func (a *Agent) filterUnsupportedExts(diffs []model.Diff) []model.Diff {
}
if skipped > 0 {
fmt.Printf("[ocr] Skipped %d file(s) with unsupported extensions\n", skipped)
fmt.Fprintf(stdout.Writer(), "[ocr] Skipped %d file(s) with unsupported extensions\n", skipped)
}
return kept
}
@ -551,7 +552,7 @@ func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFi
atomic.AddInt64(&a.totalInputTokens, int64(resp.Usage.PromptTokens+resp.Usage.CacheReadTokens))
atomic.AddInt64(&a.totalOutputTokens, int64(resp.Usage.CompletionTokens+resp.Usage.CacheWriteTokens))
}
fmt.Printf("[ocr] Plan completed for %s\n", newPath)
fmt.Fprintf(stdout.Writer(), "[ocr] Plan completed for %s\n", newPath)
return resp.Content(), nil
}
@ -639,7 +640,7 @@ func (a *Agent) performLlmCodeReview(ctx context.Context, messages []llm.Message
if len(calls) == 0 {
// No tool calls - remind the model
fmt.Printf("[ocr] No tool calls parsed for %s, retrying...\n", newPath)
fmt.Fprintf(stdout.Writer(), "[ocr] No tool calls parsed for %s, retrying...\n", newPath)
messages = append(messages, llm.NewTextMessage("user", "You did not successfully call any tools. Please try again or use task_done if finished."))
if content != "" {
messages = append(messages[:len(messages)-1], llm.NewTextMessage("assistant", content), messages[len(messages)-1])
@ -680,19 +681,19 @@ func (a *Agent) performLlmCodeReview(ctx context.Context, messages []llm.Message
break
}
if !hasValidResult {
fmt.Printf("[ocr] No valid tool results for %s, stopping.\n", newPath)
fmt.Fprintf(stdout.Writer(), "[ocr] No valid tool results for %s, stopping.\n", newPath)
break
}
succeed := a.addNextMessage(content, calls, results, &messages, newPath)
if !succeed {
fmt.Printf("[ocr] Context compression exceeded threshold for %s, stopping.\n", newPath)
fmt.Fprintf(stdout.Writer(), "[ocr] Context compression exceeded threshold for %s, stopping.\n", newPath)
break
}
}
if toolReqCount <= 0 {
fmt.Printf("[ocr] Max tool requests reached for %s.\n", newPath)
fmt.Fprintf(stdout.Writer(), "[ocr] Max tool requests reached for %s.\n", newPath)
}
return nil
@ -818,7 +819,7 @@ func BuildToolDefs(entries []toolsconfig.ToolConfigEntry, planOnly bool) []llm.T
}
var fn llm.FunctionDef
if err := json.Unmarshal(defRaw, &fn); err != nil {
fmt.Printf("[ocr] WARNING: failed to parse tool definition %q: %v\n", e.Name, err)
fmt.Fprintf(stdout.Writer(), "[ocr] WARNING: failed to parse tool definition %q: %v\n", e.Name, err)
continue
}
defs = append(defs, llm.ToolDef{
@ -850,7 +851,7 @@ func (a *Agent) compressAndRecord(msgs []llm.Message, filePath string) []llm.Mes
rec := fs.AppendTaskRecord(session.MemoryCompressionTask, compressionMsgs)
if err != nil {
rec.SetError(err, duration)
fmt.Printf("[ocr] Memory compression failed: %v\n", err)
fmt.Fprintf(stdout.Writer(), "[ocr] Memory compression failed: %v\n", err)
return msgs[:2]
}
rec.SetResponse(resp, duration)

View file

@ -17,6 +17,8 @@ import (
"time"
tiktoken "github.com/pkoukk/tiktoken-go"
"github.com/open-code-review/open-code-review/internal/stdout"
)
const maxRetries = 10 // Maximum number of retry attempts with exponential backoff.
@ -459,7 +461,7 @@ func sleepWithBackoff(attempt int) {
jitter := time.Duration(rand.Int63n(int64(delay))) - delay/2
delay += jitter
fmt.Printf("[llm] Retrying in %v (attempt info)... \n", delay)
fmt.Fprintf(stdout.Writer(), "[llm] Retrying in %v (attempt info)... \n", delay)
time.Sleep(delay)
}

40
internal/stdout/stdout.go Normal file
View file

@ -0,0 +1,40 @@
package stdout
import (
"io"
"os"
"sync"
)
var (
w io.Writer = os.Stdout
mu sync.RWMutex
)
// Writer returns the current stdout writer (real stdout or discard).
func Writer() io.Writer {
mu.RLock()
defer mu.RUnlock()
return w
}
// Quiet replaces stdout with io.Discard and returns a cleanup function.
// Usage:
//
// defer stdout.Quiet()()
//
// WARNING: Quiet must ONLY be called from the main goroutine, before spawning
// any concurrent work that writes to stdout, and its returned cleanup must be
// deferred in the same goroutine. Never call Quiet from multiple goroutines
// concurrently — it is not designed for nested or parallel silencing.
func Quiet() func() {
mu.Lock()
old := w
w = io.Discard
mu.Unlock()
return func() {
mu.Lock()
w = old
mu.Unlock()
}
}

View file

@ -12,6 +12,8 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/open-code-review/open-code-review/internal/stdout"
)
// Event emits a structured event as a span with immediate end.
@ -62,10 +64,10 @@ func FormatDuration(dur time.Duration) string {
// PrintTraceSummary prints a one-line summary of the review to stdout.
func PrintTraceSummary(filesReviewed, commentsGenerated int64, inputTokens, outputTokens, totalTokens int64, duration time.Duration) {
if inputTokens > 0 || outputTokens > 0 {
fmt.Printf("[ocr] Summary: %d file(s) reviewed, %d comment(s), ~%d token(s) used (input: ~%d, output: ~%d), %s elapsed\n",
fmt.Fprintf(stdout.Writer(), "[ocr] Summary: %d file(s) reviewed, %d comment(s), ~%d token(s) used (input: ~%d, output: ~%d), %s elapsed\n",
filesReviewed, commentsGenerated, totalTokens, inputTokens, outputTokens, FormatDuration(duration))
} else {
fmt.Printf("[ocr] Summary: %d file(s) reviewed, %d comment(s), ~%d token(s) used, %s elapsed\n",
fmt.Fprintf(stdout.Writer(), "[ocr] Summary: %d file(s) reviewed, %d comment(s), ~%d token(s) used, %s elapsed\n",
filesReviewed, commentsGenerated, totalTokens, FormatDuration(duration))
}
}
@ -76,16 +78,16 @@ func PrintTraceSummary(filesReviewed, commentsGenerated int64, inputTokens, outp
func PrintToolCallStarted(toolName string, args map[string]any) {
summary := summarizeArgs(args)
if summary != "" {
fmt.Printf("[ocr] ▶ %s %s\n", toolName, summary)
fmt.Fprintf(stdout.Writer(), "[ocr] ▶ %s %s\n", toolName, summary)
} else {
fmt.Printf("[ocr] ▶ %s\n", toolName)
fmt.Fprintf(stdout.Writer(), "[ocr] ▶ %s\n", toolName)
}
}
// PrintToolCallFinished prints a line when a tool finishes successfully.
// Example: [ocr] ✔ file_read "internal/config/rules/loader.go" (12ms)
func PrintToolCallFinished(toolName string, dur time.Duration) {
fmt.Printf("[ocr] ✔ %s (%s)\n", toolName, FormatDuration(dur))
fmt.Fprintf(stdout.Writer(), "[ocr] ✔ %s (%s)\n", toolName, FormatDuration(dur))
}
// PrintToolCallError prints a line when a tool fails.