Architecture
A walk-through of how kaid 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.
Reading order
Start with the high-level pipeline, then follow the sections in order. If you are tuning cost or debugging a skipped file, jump straight to token budget guards and the five-gate file filter.
High-level pipeline¶
flowchart TD
A["<b>kaid review</b>"]
B["<b>bootstrap</b><br/><span style='font-size:0.85em'>Resolve LLM endpoint (config → env → rc files)<br/>Load template, tool registry, system rules</span>"]
C["<b>diff provider</b><br/><span style='font-size:0.85em'>git diff / ls-files / show — produce []model.Diff<br/>Modes: Workspace · Commit · Range</span>"]
D["<b>filter & rules</b><br/><span style='font-size:0.85em'>5-gate filter (selection.go) — drop binaries,<br/>excluded paths, unsupported extensions. Pick rule per file.</span>"]
D2["<b>semantic grouping</b><br/><span style='font-size:0.85em'>One LLM call over file metadata — bundle related<br/>files into groups (max 10 files each)</span>"]
E["<b>subtask dispatch</b><br/><span style='font-size:0.85em'>For every group in parallel (concurrency=N):<br/>Plan phase (optional) → Main loop × rounds → Comments</span>"]
F["<b>output writer</b><br/><span style='font-size:0.85em'>Synchronous line-resolution & review-filter; renders text<br/>or JSON depending on --format / --audience.</span>"]
A --> B --> C --> D --> D2 --> E --> F
The orchestration lives in the
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/.
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 <sha> / -c <sha> |
the changes introduced by <sha> (via git show <sha>, equivalent to the <sha>^..<sha> diff) |
Range |
--from <a> --to <b> |
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.
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 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:
binary— binary files are dropped first.user_exclude— your project'sexcludealways wins.user_include— if the filter has include patterns and the file matches one, it's kept immediately (returns empty), bypassing theunsupported_extanddefault_pathgates below.unsupported_extfilters by extension allowlist.default_pathis the last gate: it matches built-in exclude patterns, both test files (**/*_test.go,**/__tests__/**,**/*_spec.rb, …) and dependency or build-output directories (**/node_modules/**,**/vendor/**,**/target/**,**/__pycache__/**, …). Every pattern is rooted with a**/prefix.
The same noisy directories are also filtered earlier, at the diff-provider
level, via the providerDirIgnoreDirs list in internal/diff/git.go. That
list matches by path prefix, so it catches only a directory at the
repository root: vendor/pkg/x.go never reaches the per-file filter,
while api/vendor/pkg/x.go does and is excluded by default_path instead.
The difference is user-visible. Preview reports the first as
provider_directory and an include rule cannot make it reviewable; it
reports the second as default_path, which an include rule can override.
Run kaid review --preview to see the full filter result without spending
a token. See Review Rules for
the full algorithm.
Semantic file grouping¶
Files that survive filtering are not reviewed one at a time. Before
dispatch, groupDiffs (in
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, kaid 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:
// 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 kaid
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 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:
task_donewas called.MAX_TOOL_REQUEST_TIMESran out.- 3 consecutive rounds produced no valid tool results
(
maxConsecutiveEmptyRounds = 3). - The context was cancelled.
addNextMessagereturned 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. kaid
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_TOKENSis 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 asmax_completion_tokenson every request (Template.CompletionTokenLimit()). Keeping them apart means raising the prompt ceiling with--max-tokensfor a large-context model never silently inflates the output budget. WhenMAX_COMPLETION_TOKENSis unset,MAX_TOKENSis used as the output cap for backwards compatibility.
The three zones¶
flowchart LR
subgraph messages["messages"]
direction LR
F["<b>frozen</b><br/>first 2 msgs<br/>(system +<br/>initial user)"]
C["<b>compress</b><br/>summarized<br/>into one<br/>user msg"]
A["<b>active</b><br/>K most recent<br/>complete<br/>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 <previous_review_summary> tags.
After compression: messages = frozen[2] + compressed_user_msg + active.
// 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<previous_review_summary>\n"+rawSummary+"\n</previous_review_summary>")
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:
- Line resolution (in-worker) —
existing_codeis matched against the diff using a sliding-window algorithm to compute precisestart_line/end_line. If matching fails, both default to0— a0line range is the implicit signal for an "unanchored" comment the user must locate manually (there is no stored flag; downstream consumers checkstart_line == 0). - Re-location task (optional fallback) — when line resolution
fails on a non-trivial diff, kaid runs the
RE_LOCATION_TASKprompt asking the model to re-anchor the snippet. Useful for paraphrasedexisting_codestrings. - Review filter — after the main loop finishes (and the worker pool
drains), the
REVIEW_FILTER_TASKLLM call inspects the collected comments against the diff and removes ones that are provably incorrect. Errors here are logged and ignored. - Second line-resolution pass — once
Agent.Runreturns, the top-level command re-runsdiff.ResolveLineNumbersover the full comment set (seecmd/kaid/review_cmd.go) to catch comments whoseexisting_codespans multiple files or was updated by the re-location step. - Render — into text or JSON depending on
--format.
Token budget guards¶
Before the LLM is even called, kaid runs a fail-fast check:
tokenLimit := MaxTokens * 4 / 5 // 80 %
if countMessagesTokens(messages) > tokenLimit {
record warning "token_threshold_exceeded"
return nil // skip this group
}
In other words, the guard trips when the message tokens exceed
This catches monstrous diffs (auto-generated lock files, refactors
touching thousands of lines) before they cost a request. The skipped
group is reported as 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.
The template itself isn't a CLI override — to change prompts you edit
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.
Placeholder syntax caveat. All the placeholders above use double-brace
{{…}}syntax exceptRE_LOCATION_TASK, which substitutes single-brace{diff},{existing_code}, and{suggestion_content}(seeinternal/diff/relocation.go).
Persistence¶
Every review is written to disk as 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 (kaid viewer) reads these
files directly — there's no database, just append-only logs. See
Session 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.<group-key> per reviewed
group) plus a
short-lived event.<name> span at each decision point (plan.skipped,
token.threshold.exceeded, subtask.error, …). In the main review loop,
LLM requests and tool calls emit spans and are also recorded in metrics. Prompt and
response content is never attached to telemetry; the
KAID_CONTENT_LOGGING flag is plumbed but currently dead. See
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, kaid 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_searchtool calls, not shared context, and findings in them are off-limits as comment targets — themain_taskprompt 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/kaid/main.go |
review flag parsing |
cmd/kaid/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 AGENTS.md for build & test instructions.
See Also¶
- Tools - the six tools the agent loop calls.
- Review Rules - how per-file rule text is resolved.
- Session Viewer - inspect the transcripts this pipeline writes.