--- title: Architecture sidebar: order: 8 --- A walk-through of how `ocr review` actually works inside, from the moment you press Enter to the JSON that lands in your terminal. The goal is to give you enough mental model to debug behaviour, tune flags, and read the source code with confidence. ## High-level pipeline ```mermaid flowchart TD A["ocr review"] B["bootstrap
Resolve LLM endpoint (config → env → rc files)
Load template, tool registry, system rules
"] C["diff provider
git diff / ls-files / show — produce []model.Diff
Modes: Workspace · Commit · Range
"] D["filter & rules
5-gate filter (selection.go) — drop binaries,
excluded paths, unsupported extensions. Pick rule per file.
"] D2["semantic grouping
One LLM call over file metadata — bundle related
files into groups (max 10 files each)
"] E["subtask dispatch
For every group in parallel (concurrency=N):
Plan phase (optional) → Main loop × rounds → Comments
"] F["output writer
Synchronous line-resolution & review-filter; renders text
or JSON depending on --format / --audience.
"] A --> B --> C --> D --> D2 --> E --> F ``` The orchestration lives in the [`internal/agent/`](https://github.com/alibaba/open-code-review/blob/main/internal/agent/) package, whose main files are `agent.go` (dispatch & per-group orchestration), `grouping.go` (semantic file grouping), `selection.go` (the file filter), `preview.go` (the `--preview` report), and `util.go` (helpers); the tool-use loop and memory compression live alongside it in [`internal/llmloop/`](https://github.com/alibaba/open-code-review/blob/main/internal/llmloop/). Two entry points matter: `Agent.Run` (top of pipeline) and `Agent.dispatchSubtasks` (per-group fan-out). ## The diff provider `internal/diff/git.go` defines a `Provider` struct whose unexported `mode` field (of type `Mode`, an `int` enum) selects one of three modes that mirror the CLI flags: | Mode | Triggered by | What it returns | |---|---|---| | `Workspace` | no flags | staged + unstaged + untracked changes | | `Commit` | `--commit ` / `-c ` | the changes introduced by `` (via `git show `, equivalent to the `^..` diff) | | `Range` | `--from --to ` | `merge-base(a, b)..b` | Each diff carries: old/new path, old/new hunks, insertion/deletion counts, binary flag, and rename detection. `DiffContextLines` is fixed at **3** — the same default Git uses. Untracked files are read from disk and treated as full-file additions so they're reviewed pre-commit. ## The five-gate file filter Once diffs are loaded, every file passes through [`whyExcluded`](https://github.com/alibaba/open-code-review/blob/main/internal/agent/selection.go). The function returns one of: ``` binary — file is binary user_exclude — matched a pattern in your `exclude` list unsupported_ext — extension is not in supported_file_types.json default_path — matched a built-in test-file exclude pattern ``` …or empty if the file is kept. `deleted` and `too_large` are **not** returned by `whyExcluded`; `selectFiles` applies them after the gates — `deleted` when a kept file's diff reports `IsDeleted`, `too_large` when its raw diff alone exceeds 80% of `max_tokens`. The gates run in this order: 1. `binary` — binary files are dropped first. 2. `user_exclude` — your project's `exclude` always wins. 3. `user_include` — if the filter has include patterns **and** the file matches one, it's kept immediately (returns empty), bypassing the `unsupported_ext` and `default_path` gates below. 4. `unsupported_ext` filters by extension allowlist. 5. `default_path` is the last gate: it matches built-in **test-file** exclude patterns (`**/*_test.go`, `**/*.test.{js,jsx,ts,tsx}`, `**/__tests__/**`, `**/*_test.py`, `**/*_spec.rb`, `**/*.test.ets`, …). Every pattern is rooted with a `**/` prefix. The noisy-directory filtering (`vendor/`, `node_modules/`, `target/`, …) happens earlier, at the diff-provider level, via the `providerDirIgnoreDirs` list in `internal/diff/git.go`. Preview reports these files as `provider_directory`; they never reach the per-file filter, and an `include` rule cannot make them reviewable. Run `ocr review --preview` to see the full filter result without spending a token. See [Review Rules](../review-rules/#how-files-are-filtered) for the full algorithm. ## Semantic file grouping Files that survive filtering are **not** reviewed one at a time. Before dispatch, `groupDiffs` (in [`grouping.go`](https://github.com/alibaba/open-code-review/blob/main/internal/agent/grouping.go)) makes a single `GROUPING_TASK` LLM call carrying only file *metadata* — path, status (`ADDED` / `MODIFIED` / `DELETED` / `RENAMED`), and insertion/deletion counts — never diff content. The model returns a JSON array of `{label, files}` objects, and each group is then reviewed in one shared conversation so the agent can reason across related changes (handler + service + test, or a rename and its call sites). Three guards keep groups sane: | Guard | Effect | |---|---| | `maxFilesPerGroup = 10` | Oversized groups are split into 10-file chunks. | | Token budget | A group whose combined diffs exceed the prompt limit is split back into single-file groups. | | Coverage | Any file the model failed to assign gets its own single-file group. | Grouping is a best-effort optimisation, never a correctness gate: a failed, empty, or unparseable response logs a warning and falls back to one-file-per-group dispatch — exactly the old behaviour. The resulting grouping is also surfaced in JSON output. ## Per-group subtask: plan + main For every group, OCR fires a sub-agent. Each sub-agent runs in its own goroutine, bounded by `--concurrency` (default **8**), and has its own LLM message buffer. A subtask has up to **two phases**: ### Phase 1 — Plan (optional) The plan phase is gated by `Template.PlanRequired`, which cooperates two thresholds: ```go // PLAN_MODE_LINE_THRESHOLD = 50, PLAN_MODE_GROUP_LINE_THRESHOLD = 100 if maxFileChanged >= PlanModeLineThreshold { plan } // one big rewrite if fileCount >= 2 && total >= PlanModeGroupLineThreshold { plan } // several moderate files ``` The per-file threshold catches a single large rewrite; the group threshold catches several moderate files that together warrant structured guidance. The group threshold is deliberately the larger of the two so the plan phase doesn't become unconditional for multi-file groups. For small changes the plan adds latency without value, so it's skipped silently and the main loop runs straight away. Otherwise OCR makes a **single** `PLAN_TASK` LLM call — no `Tools` field is sent, so the model cannot call tools during planning. The read-only tool subset (`code_search`, `file_read_diff`, `file_find` — the three tools whose `plan_task` flag is `true` in `tools.json`) is embedded as plain text via the `{{plan_tools}}` placeholder (rendered by `formatToolDefs`) so the model knows what's available later. The model returns a checklist that becomes `{{plan_guidance}}` in the main prompt. ### Phase 2 — Main loop The main loop assembles the `MAIN_TASK` prompt and runs a tool-use conversation with the model. The full tool set adds **`task_done`**, **`code_comment`**, and **`file_read`** to the plan-phase tools — see [Tools](../tools/) for the full catalogue. ``` loop up to MAX_TOOL_REQUEST_TIMES (default 100): response = llm.complete(messages, tools) if response.toolCalls is empty: nudge model with "You did not successfully call any tools. Please try again or use task_done if finished." continue for each call: execute → collect result if any call was task_done: break addNextMessage(...) # may trigger compression ``` The loop has five exit conditions: 1. `task_done` was called. 2. `MAX_TOOL_REQUEST_TIMES` ran out. 3. 3 consecutive rounds produced no valid tool results (`maxConsecutiveEmptyRounds = 3`). 4. The context was cancelled. 5. `addNextMessage` returned false — compression couldn't bring the message buffer back under the warning threshold. In all cases collected `code_comment` calls become review comments. ### Review rounds The main loop is not run once but up to `MAX_REVIEW_ROUNDS` times per group, to improve recall on large groups. The round count is set by the `--effort` preset: | `--effort` | Rounds | |---|---| | `low` | 1 | | `medium` (default) | 2 | | `high` | 3 | Each round after the first re-runs `MAIN_TASK` with the findings already confirmed by earlier rounds injected as `{{confirmed_comments}}`, and **without** the plan — a plan tends to act as a coverage ceiling once the obvious issues are found. Rounds stop early when a round adds no new findings, when the confirmed-comment cap is reached, or when the aggregate token budget (`--max-tokens-budget`) is exhausted. ## Memory compression A long tool-use loop will eventually overflow the context window. OCR manages this with a **three-zone partitioning** strategy that triggers on the prompt budget defined by `MAX_TOKENS = 200000`: | Threshold | Constant | Action | |---|---|---| | 60 % of MAX_TOKENS | `tokenSoftThreshold` | Kick off **async** background compression; current loop continues uninterrupted. | | 80 % of MAX_TOKENS | `tokenWarningThreshold` | Run compression **synchronously** before sending the next request. | > **`MAX_TOKENS` is an *input* ceiling.** It bounds the prompt — the > context-window budget the message buffer is compressed against — and > nothing else. The model's *output* cap is a separate knob, > `MAX_COMPLETION_TOKENS = 16384`, sent as `max_completion_tokens` on > every request (`Template.CompletionTokenLimit()`). Keeping them apart > means raising the prompt ceiling with `--max-tokens` for a > large-context model never silently inflates the output budget. When > `MAX_COMPLETION_TOKENS` is unset, `MAX_TOKENS` is used as the output > cap for backwards compatibility. ### The three zones ```mermaid flowchart LR subgraph messages["messages"] direction LR F["frozen
first 2 msgs
(system +
initial user)"] C["compress
summarized
into one
user msg"] A["active
K most recent
complete
rounds"] end F --- C --- A ``` A "round" is one assistant message plus the tool result messages that followed it. `partitionMessages` walks rounds from the end, keeping as many as fit within `(0.80 × MAX_TOKENS) - reservedTokens`. Everything older becomes the **compress zone**. The compress zone is rendered as XML and fed to the model with the `MEMORY_COMPRESSION_TASK` prompt; the returned summary is appended to the original user message inside `` tags. After compression: `messages = frozen[2] + compressed_user_msg + active`. ```go // compression.go func (a *Agent) runCompression(ctx context.Context, msgs []llm.Message, filePath string) ([]llm.Message, error) { part := partitionMessages(msgs, a.args.Template.MaxTokens, 0) contextXML := buildMessageXML(msgs[part.frozenEnd:part.compressEnd]) // … call MEMORY_COMPRESSION_TASK … rebuilt[1] = llm.NewTextMessage(role, currentText+ "\n\n\n"+rawSummary+"\n") for i := part.compressEnd; i < len(msgs); i++ { rebuilt = append(rebuilt, msgs[i]) } return rebuilt, nil } ``` ### Async vs sync The async path lets the main loop keep emitting tool calls while compression runs in the background; when the next token check happens, a ready summary is swapped in via `tryApplyPendingCompression`. If the ratio crosses the warning threshold before the async job finishes, the loop stalls and runs `runCompression` synchronously — guaranteeing the next request always fits. ## Comment processing pipeline Every `code_comment` tool call produces one or more raw comments. They go through a **CommentWorkerPool** (a fixed-size goroutine pool) so the main tool-use loop never blocks on post-processing: 1. **Line resolution** (in-worker) — `existing_code` is matched against the diff using a sliding-window algorithm to compute precise `start_line` / `end_line`. If matching fails, both default to `0` — a `0` line range is the implicit signal for an "unanchored" comment the user must locate manually (there is no stored flag; downstream consumers check `start_line == 0`). 2. **Re-location task** *(optional fallback)* — when line resolution fails on a non-trivial diff, OCR runs the `RE_LOCATION_TASK` prompt asking the model to re-anchor the snippet. Useful for paraphrased `existing_code` strings. 3. **Review filter** — after the main loop finishes (and the worker pool drains), the `REVIEW_FILTER_TASK` LLM call inspects the collected comments against the diff and removes ones that are provably incorrect. Errors here are logged and ignored. 4. **Second line-resolution pass** — once `Agent.Run` returns, the top-level command re-runs `diff.ResolveLineNumbers` over the full comment set (see `cmd/opencodereview/review_cmd.go`) to catch comments whose `existing_code` spans multiple files or was updated by the re-location step. 5. **Render** — into text or JSON depending on `--format`. ## Token budget guards Before the LLM is even called, OCR runs a fail-fast check: ```go tokenLimit := MaxTokens * 4 / 5 // 80 % if countMessagesTokens(messages) > tokenLimit { record warning "token_threshold_exceeded" return nil // skip this group } ``` This catches monstrous diffs (auto-generated lock files, refactors touching thousands of lines) before they cost a request. The skipped group is reported as a non-fatal warning in stdout and added to the JSON `warnings` array. A second check runs in `selectFiles`: if the diff alone exceeds 80 % of `MAX_TOKENS` it's filtered out before grouping and dispatch even happen, and reported as `too_large`. A third guard runs inside grouping — see `enforceGroupTokenBudget` above. ## The template & placeholders `internal/config/template/task_template.json` holds **six prompts**: | Key | Purpose | |---|---| | `GROUPING_TASK` | Bundles the changed files into semantic groups. | | `PLAN_TASK` | Planning phase — produces a checklist. | | `MAIN_TASK` | Main review loop — emits `code_comment` calls. | | `MEMORY_COMPRESSION_TASK` | Summarises the compress zone. | | `REVIEW_FILTER_TASK` | Post-loop pass that removes provably-incorrect comments. | | `RE_LOCATION_TASK` | Re-anchors a comment whose `existing_code` couldn't be matched. | Each prompt is a list of `{role, prompt_file}` references that point to `.md` files in the template directory (e.g. `{"role": "system", "prompt_file": "main_task_system.md"}`). At load time `resolveConversation` reads those files into in-memory `{role, content}` messages, and template placeholders are then resolved per-group: | Placeholder | Replaced with | |---|---| | `{{system_rule}}` | The rule bodies resolved from the four-layer chain, merged across the group's files. | | `{{change_files}}` | Status + path of every changed file in the PR *outside* this group. | | `{{diffs}}` | The group's diffs, one XML element per file. | | `{{plan_guidance}}` | Output of the plan phase, or removed when plan is skipped or on rounds 2+. | | `{{confirmed_comments}}` | Findings confirmed by earlier review rounds (empty on round 1). | | `{{plan_tools}}` | Plan-phase tool definitions as plain text (rendered by `formatToolDefs`), used in the `PLAN_TASK` system prompt. | | `{{requirement_background}}` | The effective background from `--background` or `--background-file` (file takes precedence). | | `{{current_system_date_time}}` | Local timestamp for the run, formatted `YYYY-MM-DD HH:MM` (no seconds or timezone). | | `{{file_list}}` | (grouping only) file metadata — path, status, `+/-` counts. | | `{{context}}` | (compression only) the XML-rendered messages to summarise. | | `{{path}}` | Group key (comma-joined sorted paths), used in `REVIEW_FILTER_TASK`. | | `{{comments}}` | Accumulated comments (JSON), used in `REVIEW_FILTER_TASK`. | The placeholder substitution lives in [`agent.go`](https://github.com/alibaba/open-code-review/blob/main/internal/agent/agent.go). The template itself isn't a CLI override — to change prompts you edit [`task_template.json`](https://github.com/alibaba/open-code-review/blob/main/internal/config/template/task_template.json) and rebuild. The `--tools` flag is a *tool-registry* override (it swaps the JSON consumed by `internal/config/toolsconfig`), not a template override — see [Tools](../tools/#customizing-tools). > **Placeholder syntax caveat.** All the placeholders above use > double-brace `{{…}}` syntax *except* `RE_LOCATION_TASK`, which > substitutes single-brace `{diff}`, `{existing_code}`, and > `{suggestion_content}` (see `internal/diff/relocation.go`). ## Persistence Every review is written to disk as JSONL: ``` ~/.opencodereview/sessions//.jsonl ``` The repo path is **not** base64-encoded; `encodeRepoPath` (in `internal/session/persist.go`) replaces `/` and `\` with `-` and `:` with `_` so the path is filesystem-safe. Each line is one event: prompt sent, LLM response, tool call, tool result, comment emitted, etc. The Web UI (`ocr viewer`) reads these files directly — there's no database, just append-only logs. See [Session Viewer](../viewer/) for the UI tour and event schema. ## Telemetry When telemetry is enabled the agent emits three pipeline-level spans (`review.run` wrapping the whole job, `diff.parse` wrapping diff loading, and one `subtask.execute.group.` per reviewed group) plus a short-lived `event.` span at each decision point (`plan.skipped`, `token.threshold.exceeded`, `subtask.error`, …). LLM round trips and tool calls are recorded only as metrics — not as spans. Prompt and response content is **never** attached to telemetry; the `OCR_CONTENT_LOGGING` flag is plumbed but currently dead. See [Telemetry](../telemetry/) for the full schema. ## What's *not* automated A few decisions are deliberately manual: - **Endpoint discovery has no fallback.** If your config + env + rc files don't yield a complete `(URL, token, model)` triple, OCR exits with a non-zero code rather than guessing. - **Sub-agent failures are isolated, not retried.** One failing group produces a warning; the rest continue. Retries belong in the wrapping CI pipeline, not the agent. - **Cross-file reasoning is bounded by the group.** Files in the same semantic group share one LLM conversation, so the agent can reason across them directly. Files in *other* groups are reachable only through `file_read_diff` / `code_search` tool calls, not shared context, and findings in them are off-limits as comment targets — the `main_task` prompt instructs the model to use context tools for understanding only, and to ignore issues that surface outside the diffs it was given. These choices keep the run **deterministic per-group** and keep cost predictable. ## Source-code map If you want to read along: | Concern | File | |---|---| | Top-level command dispatch | `cmd/opencodereview/main.go` | | `review` flag parsing | `cmd/opencodereview/shared_flags.go` | | Agent orchestration | `internal/agent/` (agent.go, util.go) | | Semantic file grouping | `internal/agent/grouping.go` | | Tool-use loop & memory compression | `internal/llmloop/` (loop.go, compression.go) | | Effort presets | `internal/config/template/effort.go` | | File filter / preview | `internal/agent/selection.go`, `internal/agent/preview.go` | | Diff loading (Git modes) | `internal/diff/git.go` | | Rule resolution chain | `internal/config/rules/system_rules.go` | | Tool registry & impls | `internal/tool/` | | LLM endpoint resolver | `internal/llm/resolver.go` | | Session JSONL writer | `internal/session/persist.go` | | Web viewer | `internal/viewer/server.go` | See [Contributing](../contributing/) for build & test instructions. ## See Also - [Tools](../tools/) — the six tools the agent loop calls. - [Review Rules](../review-rules/) — how per-file rule text is resolved. - [Session Viewer](../viewer/) — inspect the transcripts this pipeline writes.