@effect-agent/pr-review 0.1.0-beta.28 → 0.1.0-beta.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +11 -204
  2. package/dist/index.d.mts +92 -914
  3. package/dist/index.mjs +176 -71
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +3 -18
  6. package/src/index.ts +1 -25
  7. package/src/review.ts +244 -0
  8. package/dist/action.d.mts +0 -215
  9. package/dist/action.mjs +0 -505
  10. package/dist/action.mjs.map +0 -1
  11. package/dist/cli.d.mts +0 -1
  12. package/dist/cli.mjs +0 -106
  13. package/dist/cli.mjs.map +0 -1
  14. package/dist/fan-out-C3yG1cx3.d.mts +0 -1526
  15. package/dist/github-CCuLgyqb.mjs +0 -3437
  16. package/dist/github-CCuLgyqb.mjs.map +0 -1
  17. package/dist/logging-Q4j0oub-.mjs +0 -75
  18. package/dist/logging-Q4j0oub-.mjs.map +0 -1
  19. package/dist/providers-Br9FRn7j.mjs +0 -1349
  20. package/dist/providers-Br9FRn7j.mjs.map +0 -1
  21. package/dist/testing.d.mts +0 -86
  22. package/dist/testing.mjs +0 -184
  23. package/dist/testing.mjs.map +0 -1
  24. package/src/action.ts +0 -906
  25. package/src/cli.ts +0 -235
  26. package/src/internal/action-entry.ts +0 -45
  27. package/src/internal/adjudication.ts +0 -415
  28. package/src/internal/anchors.ts +0 -20
  29. package/src/internal/coverage.ts +0 -357
  30. package/src/internal/diff.ts +0 -193
  31. package/src/internal/effort.ts +0 -86
  32. package/src/internal/factory.ts +0 -357
  33. package/src/internal/fan-out-scripted.ts +0 -77
  34. package/src/internal/fan-out.ts +0 -1148
  35. package/src/internal/fingerprint.ts +0 -89
  36. package/src/internal/fixtures.ts +0 -148
  37. package/src/internal/github-env.ts +0 -164
  38. package/src/internal/github.ts +0 -1218
  39. package/src/internal/ignore.ts +0 -88
  40. package/src/internal/logging.ts +0 -124
  41. package/src/internal/profiles.ts +0 -91
  42. package/src/internal/progress.ts +0 -433
  43. package/src/internal/providers.ts +0 -133
  44. package/src/internal/render.ts +0 -819
  45. package/src/internal/retirement.ts +0 -337
  46. package/src/internal/review-agent.ts +0 -543
  47. package/src/internal/review-state.ts +0 -782
  48. package/src/internal/review-units.ts +0 -493
  49. package/src/internal/run.ts +0 -611
  50. package/src/internal/scripted.ts +0 -108
  51. package/src/internal/source.ts +0 -110
  52. package/src/testing.ts +0 -8
@@ -1,543 +0,0 @@
1
- import { Effect, Schema } from "effect";
2
- import { Agent, AgentPolicy, ToolExecutionClass, ToolResultBounds } from "effect-agent";
3
- import { Tool, Toolkit } from "effect/unstable/ai";
4
-
5
- import {
6
- annotatePatch,
7
- ChangedFileStatus,
8
- ChangedPath,
9
- hasReviewableContent,
10
- renderReviewContent,
11
- } from "./diff.ts";
12
- import type { ChangedFile } from "./diff.ts";
13
- import {
14
- normalizeRepoRelativePath,
15
- PullRequestSource,
16
- PullRequestSourceFailure,
17
- ReviewInputViolation,
18
- } from "./source.ts";
19
-
20
- // ---------------------------------------------------------------------------
21
- // The pull-request reviewer: a bounded, read-only agent. Every tool observes
22
- // the pull request through the PullRequestSource port; nothing the model can
23
- // call mutates anything. Publishing the review happens OUTSIDE the agent
24
- // loop, after the finding anchors have been validated against the real diff
25
- // (model output is untrusted input, AGENTS.md rule 11).
26
- // ---------------------------------------------------------------------------
27
-
28
- /** The hard findings bound carried by the CodeReview schema. */
29
- export const MAX_FINDINGS = 20;
30
-
31
- /** The hard non-anchored-concerns bound carried by the CodeReview schema. */
32
- export const MAX_CONCERNS = 10;
33
-
34
- /** Maximum characters in one deterministic model-visible evidence chunk. */
35
- export const MAX_PATCH_CHARS = 60_000;
36
-
37
- /** The encoded Tool result must retain one complete bounded content fallback. */
38
- export const REVIEW_TOOL_RESULT_MAX_BYTES = 2 * 1024 * 1024;
39
-
40
- /** One `read_file` slice never exceeds this many lines. */
41
- const MAX_SLICE_LINES = 1_000;
42
- const DEFAULT_SLICE_LINES = 400;
43
-
44
- // ---------------------------------------------------------------------------
45
- // Tool surface.
46
- // ---------------------------------------------------------------------------
47
-
48
- export class ChangedFileSummary extends Schema.Class<ChangedFileSummary>(
49
- "@effect-agent/pr-review/ChangedFileSummary",
50
- )({
51
- path: ChangedPath,
52
- status: ChangedFileStatus,
53
- additions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
54
- deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
55
- hasTextualDiff: Schema.Boolean,
56
- /** True when a missing patch was recovered as bounded UTF-8 base/head content. */
57
- hasReviewableContent: Schema.Boolean,
58
- }) {}
59
-
60
- export class ChangedFilesView extends Schema.Class<ChangedFilesView>(
61
- "@effect-agent/pr-review/ChangedFilesView",
62
- )({
63
- totalFiles: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
64
- /** True when the pull request has more changed files than are listed here. */
65
- truncated: Schema.Boolean,
66
- files: Schema.Array(ChangedFileSummary).check(Schema.isMaxLength(300)),
67
- }) {}
68
-
69
- export class ListChangedFilesQuery extends Schema.Class<ListChangedFilesQuery>(
70
- "@effect-agent/pr-review/ListChangedFilesQuery",
71
- )({
72
- /** Explicit constant keeps the zero-choice operation compatible with strict provider schemas. */
73
- scope: Schema.Literal("all"),
74
- }) {}
75
-
76
- export const ListChangedFiles = Tool.make("list_changed_files", {
77
- description:
78
- "List every file changed by this pull request with its status, line counts, and whether a textual diff is available.",
79
- parameters: ListChangedFilesQuery,
80
- success: ChangedFilesView,
81
- failure: PullRequestSourceFailure,
82
- failureMode: "error",
83
- dependencies: [PullRequestSource],
84
- }).annotate(ToolExecutionClass, "readonly");
85
-
86
- export class FileDiffQuery extends Schema.Class<FileDiffQuery>(
87
- "@effect-agent/pr-review/FileDiffQuery",
88
- )({
89
- path: ChangedPath,
90
- }) {}
91
-
92
- export class FileDiffView extends Schema.Class<FileDiffView>(
93
- "@effect-agent/pr-review/FileDiffView",
94
- )({
95
- path: ChangedPath,
96
- status: ChangedFileStatus,
97
- reviewMode: Schema.Literals(["diff", "content", "unavailable"]),
98
- /**
99
- * The unified diff with explicit RIGHT-side line numbers: `R<n>` marks a
100
- * line present in the new file version (only those may anchor findings);
101
- * `-` marks removed lines. For content fallback, `B<n>` and `H<n>`
102
- * identify base/head lines for reading only; they are never valid anchors.
103
- * Empty only when neither a patch nor bounded textual content exists.
104
- */
105
- annotatedPatch: Schema.String,
106
- truncated: Schema.Boolean,
107
- }) {}
108
-
109
- export interface FileReviewEvidenceChunk {
110
- readonly reviewMode: "diff" | "content" | "unavailable";
111
- readonly annotatedPatch: string;
112
- }
113
-
114
- /**
115
- * Split complete model-visible evidence at deterministic line boundaries.
116
- * A pathological single line is hard-sliced so every character is still
117
- * assigned and every chunk remains within the provider-independent bound.
118
- */
119
- const boundedEvidenceChunks = (evidence: string): ReadonlyArray<string> => {
120
- if (evidence.length <= MAX_PATCH_CHARS) return [evidence];
121
- const chunks: Array<string> = [];
122
- let offset = 0;
123
- while (offset < evidence.length) {
124
- let end = Math.min(offset + MAX_PATCH_CHARS, evidence.length);
125
- if (end < evidence.length) {
126
- const boundary = evidence.lastIndexOf("\n", end - 1);
127
- if (boundary >= offset) end = boundary + 1;
128
- }
129
- // No newline exists inside the bound: preserve complete input with a
130
- // deterministic hard slice instead of silently truncating the line.
131
- if (end === offset) end = Math.min(offset + MAX_PATCH_CHARS, evidence.length);
132
- chunks.push(evidence.slice(offset, end));
133
- offset = end;
134
- }
135
- return chunks;
136
- };
137
-
138
- /** Complete bounded evidence chunks used by deterministic fan-out planning. */
139
- export const fileReviewEvidenceChunks = (
140
- file: ChangedFile,
141
- ): ReadonlyArray<FileReviewEvidenceChunk> => {
142
- const contentEvidence = renderReviewContent(file);
143
- const reviewMode =
144
- file.patch !== undefined
145
- ? ("diff" as const)
146
- : contentEvidence !== undefined
147
- ? ("content" as const)
148
- : ("unavailable" as const);
149
- const annotated = file.patch === undefined ? (contentEvidence ?? "") : annotatePatch(file.patch);
150
- return boundedEvidenceChunks(annotated).map((annotatedPatch) => ({
151
- reviewMode,
152
- annotatedPatch,
153
- }));
154
- };
155
-
156
- /** Host-owned rendering of one changed file's bounded review evidence. */
157
- export const fileDiffView = (file: ChangedFile): FileDiffView => {
158
- const chunks = fileReviewEvidenceChunks(file);
159
- const first = chunks[0] ?? { reviewMode: "unavailable" as const, annotatedPatch: "" };
160
- const truncated = first.reviewMode === "diff" && chunks.length > 1;
161
- return FileDiffView.make({
162
- path: file.path,
163
- status: file.status,
164
- reviewMode: first.reviewMode,
165
- annotatedPatch: truncated ? `${first.annotatedPatch}\n[diff truncated]` : first.annotatedPatch,
166
- truncated,
167
- });
168
- };
169
-
170
- // Read failures stay model-visible results ("return"), never run-killers:
171
- // a model asking for an out-of-changeset path is expected untrusted-input
172
- // behavior, and the fail-closed answer is a typed refusal it can correct —
173
- // aborting the whole review on one bad path guess would be fragility, not
174
- // security (the run stays bounded by AgentPolicy regardless).
175
- export const ReadFileDiff = Tool.make("read_file_diff", {
176
- description:
177
- "Read one changed file's review evidence. A normal unified diff marks valid anchors as R<number>. When GitHub omitted the diff, bounded base/head content is returned with B/H line labels for review but no valid inline anchors.",
178
- parameters: FileDiffQuery,
179
- success: FileDiffView,
180
- failure: Schema.Union([PullRequestSourceFailure, ReviewInputViolation]),
181
- failureMode: "return",
182
- dependencies: [PullRequestSource],
183
- }).annotate(ToolExecutionClass, "readonly");
184
-
185
- export class FileSliceQuery extends Schema.Class<FileSliceQuery>(
186
- "@effect-agent/pr-review/FileSliceQuery",
187
- )({
188
- path: ChangedPath,
189
- /** 1-based first line to read; defaults to 1. */
190
- startLine: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))),
191
- /** Number of lines to read; defaults to 400, capped at 1000. */
192
- maxLines: Schema.optionalKey(
193
- Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThanOrEqualTo(MAX_SLICE_LINES)),
194
- ),
195
- }) {}
196
-
197
- export class FileSlice extends Schema.Class<FileSlice>("@effect-agent/pr-review/FileSlice")({
198
- path: ChangedPath,
199
- startLine: Schema.Int.check(Schema.isGreaterThan(0)),
200
- endLine: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
201
- totalLines: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
202
- /** Slice content with each line prefixed by its 1-based line number. */
203
- content: Schema.String,
204
- }) {}
205
-
206
- export const ReadFile = Tool.make("read_file", {
207
- description:
208
- "Read a numbered slice of the NEW (head) version of one changed file, for context around the diff. Only files in the changeset are readable.",
209
- parameters: FileSliceQuery,
210
- success: FileSlice,
211
- failure: Schema.Union([PullRequestSourceFailure, ReviewInputViolation]),
212
- failureMode: "return",
213
- dependencies: [PullRequestSource],
214
- }).annotate(ToolExecutionClass, "readonly");
215
-
216
- export const ReviewToolkit = Toolkit.make(ListChangedFiles, ReadFileDiff, ReadFile);
217
-
218
- /**
219
- * The `list_changed_files` handler, shared by the flat reviewer's toolkit and
220
- * any extended toolkit built by the configuration factory.
221
- */
222
- export const listChangedFilesHandler = (_query: ListChangedFilesQuery) =>
223
- Effect.gen(function* () {
224
- const source = yield* PullRequestSource;
225
- const files = yield* source.changedFiles;
226
- const metadata = yield* source.metadata;
227
- return ChangedFilesView.make({
228
- totalFiles: metadata.totalChangedFiles,
229
- truncated: files.length < metadata.totalChangedFiles,
230
- files: files.map((file) =>
231
- ChangedFileSummary.make({
232
- path: file.path,
233
- status: file.status,
234
- additions: file.additions,
235
- deletions: file.deletions,
236
- hasTextualDiff: file.patch !== undefined,
237
- hasReviewableContent: hasReviewableContent(file),
238
- }),
239
- ),
240
- });
241
- });
242
-
243
- /**
244
- * The `read_file_diff` handler, shared verbatim by the flat reviewer's
245
- * toolkit and the fan-out child's toolkit (fan-out.ts).
246
- */
247
- export const readFileDiffHandler = (query: FileDiffQuery) =>
248
- Effect.gen(function* () {
249
- const source = yield* PullRequestSource;
250
- const relative = yield* normalizeRepoRelativePath(query.path);
251
- const files = yield* source.changedFiles;
252
- const file = files.find((candidate) => candidate.path === relative);
253
- if (file === undefined) {
254
- return yield* ReviewInputViolation.make({
255
- input: relative,
256
- reason: "Path is not part of this pull request's changeset.",
257
- });
258
- }
259
- return fileDiffView(file);
260
- });
261
-
262
- /**
263
- * The `read_file` handler, shared verbatim by the flat reviewer's toolkit
264
- * and the fan-out child's toolkit (fan-out.ts).
265
- */
266
- export const readFileHandler = (query: FileSliceQuery) =>
267
- Effect.gen(function* () {
268
- const source = yield* PullRequestSource;
269
- const relative = yield* normalizeRepoRelativePath(query.path);
270
- const content = yield* source.readFile(relative);
271
- const lines = content.split("\n");
272
- const startLine = query.startLine ?? 1;
273
- const maxLines = query.maxLines ?? DEFAULT_SLICE_LINES;
274
- if (startLine > lines.length) {
275
- return yield* ReviewInputViolation.make({
276
- input: `${relative}:${startLine}`,
277
- reason: `startLine is beyond the end of the file (${lines.length} lines).`,
278
- });
279
- }
280
- const slice = lines.slice(startLine - 1, startLine - 1 + maxLines);
281
- const endLine = startLine + slice.length - 1;
282
- return FileSlice.make({
283
- path: relative,
284
- startLine,
285
- endLine,
286
- totalLines: lines.length,
287
- content: slice
288
- .map((text, index) => `${String(startLine + index).padStart(5)} ${text}`)
289
- .join("\n"),
290
- });
291
- });
292
-
293
- export const ReviewToolkitLayer = ReviewToolkit.toLayer({
294
- list_changed_files: listChangedFilesHandler,
295
- read_file_diff: readFileDiffHandler,
296
- read_file: readFileHandler,
297
- });
298
-
299
- // ---------------------------------------------------------------------------
300
- // Mission input and review output contracts.
301
- // ---------------------------------------------------------------------------
302
-
303
- /** Bounded prior-review context lines injected into reviewer instructions. */
304
- const ReviewContextLines = Schema.Array(Schema.String.check(Schema.isMaxLength(1_200))).check(
305
- Schema.isMaxLength(20),
306
- );
307
-
308
- export class ReviewMission extends Schema.Class<ReviewMission>(
309
- "@effect-agent/pr-review/ReviewMission",
310
- )({
311
- repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
312
- number: Schema.Int.check(Schema.isGreaterThan(0)),
313
- title: Schema.String.check(Schema.isMaxLength(400)),
314
- body: Schema.String.check(Schema.isMaxLength(20_000)),
315
- baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
316
- headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
317
- changedFileCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
318
- /**
319
- * Maintainer-adjudicated identities rendered as bounded context lines; the
320
- * reviewer must not re-raise them without materially new evidence. Absent
321
- * from fingerprint missions so an adjudication never invalidates the
322
- * skip-unchanged authority.
323
- */
324
- adjudicatedContext: Schema.optionalKey(ReviewContextLines),
325
- /**
326
- * Prior-round findings on re-reviewed scope, rendered as bounded context
327
- * lines; each must be confirmed, declared fixed, or explicitly withdrawn.
328
- */
329
- priorFindingContext: Schema.optionalKey(ReviewContextLines),
330
- }) {}
331
-
332
- export const FindingSeverity = Schema.Literals(["blocking", "important", "nit"]);
333
- export type FindingSeverity = typeof FindingSeverity.Type;
334
-
335
- /**
336
- * What kind of problem a finding names. Model-claimed like severity — it is a
337
- * label for scanning a busy review, never an input to the check conclusion.
338
- */
339
- export const FindingCategory = Schema.Literals([
340
- "correctness",
341
- "security",
342
- "concurrency",
343
- "performance",
344
- "resources",
345
- "error-handling",
346
- "testing",
347
- "maintainability",
348
- "style",
349
- "docs",
350
- ]);
351
- export type FindingCategory = typeof FindingCategory.Type;
352
-
353
- export class ReviewFinding extends Schema.Class<ReviewFinding>(
354
- "@effect-agent/pr-review/ReviewFinding",
355
- )({
356
- path: ChangedPath,
357
- /** 1-based line numbers in the NEW file version; must appear in the diff. */
358
- startLine: Schema.Int.check(Schema.isGreaterThan(0)),
359
- endLine: Schema.Int.check(Schema.isGreaterThan(0)),
360
- severity: FindingSeverity,
361
- /** Optional problem-kind label rendered next to the severity. */
362
- category: Schema.optionalKey(FindingCategory),
363
- title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
364
- body: Schema.NonEmptyString.check(Schema.isMaxLength(2_000)),
365
- /** Replacement for exactly lines startLine..endLine; omit when unsure. */
366
- suggestion: Schema.optionalKey(
367
- Schema.String.annotate({
368
- description:
369
- "Committable replacement source code for exactly lines startLine..endLine: the full replacement for every line in the range and nothing else — never prose describing the change, which belongs in body.",
370
- }).check(Schema.isMaxLength(2_000)),
371
- ),
372
- }) {}
373
-
374
- export const ReviewVerdict = Schema.Literals(["approve", "comment", "request-changes"]);
375
- export type ReviewVerdict = typeof ReviewVerdict.Type;
376
-
377
- /**
378
- * A concern with no diff line to anchor to: a missing deletion or cleanup,
379
- * rollout or migration sequencing, a coverage gap the diff implies but does
380
- * not add, or a scope question only the author can answer. Rendered as a
381
- * review-body section instead of an inline comment. `evidencePaths` binds the
382
- * concern to changed files so a later incremental review can invalidate and
383
- * recheck it when any supporting path changes. It remains optional only for
384
- * decoding review output and continuity state written before path binding was
385
- * introduced; a pathless concern cannot authorize incremental continuity.
386
- */
387
- export class ReviewConcern extends Schema.Class<ReviewConcern>(
388
- "@effect-agent/pr-review/ReviewConcern",
389
- )({
390
- evidencePaths: Schema.optionalKey(
391
- Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)),
392
- ),
393
- severity: FindingSeverity,
394
- title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
395
- body: Schema.NonEmptyString.check(Schema.isMaxLength(2_000)),
396
- }) {}
397
-
398
- /** The per-entry walkthrough summary bound, and the entries bound (the changeset cap). */
399
- export const MAX_WALKTHROUGH_SUMMARY_CHARS = 240;
400
- export const MAX_WALKTHROUGH_ENTRIES = 300;
401
-
402
- /**
403
- * One reviewed file's one-sentence change summary. Rendered only when the
404
- * path is actually part of the changeset — like finding anchors, walkthrough
405
- * paths are validated host-side and invented ones are dropped.
406
- */
407
- export class WalkthroughEntry extends Schema.Class<WalkthroughEntry>(
408
- "@effect-agent/pr-review/WalkthroughEntry",
409
- )({
410
- path: ChangedPath,
411
- summary: Schema.NonEmptyString.check(Schema.isMaxLength(MAX_WALKTHROUGH_SUMMARY_CHARS)),
412
- }) {}
413
-
414
- export class CodeReview extends Schema.Class<CodeReview>("@effect-agent/pr-review/CodeReview")({
415
- summary: Schema.NonEmptyString.check(Schema.isMaxLength(4_000)),
416
- verdict: ReviewVerdict,
417
- findings: Schema.Array(ReviewFinding).check(Schema.isMaxLength(MAX_FINDINGS)),
418
- /** Non-anchorable concerns; absent when the review raises none. */
419
- concerns: Schema.optionalKey(Schema.Array(ReviewConcern).check(Schema.isMaxLength(MAX_CONCERNS))),
420
- /** Per-file change summaries; absent when the model provides none. */
421
- walkthrough: Schema.optionalKey(
422
- Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_WALKTHROUGH_ENTRIES)),
423
- ),
424
- }) {}
425
-
426
- // ---------------------------------------------------------------------------
427
- // Instructions. Live models diverge wherever the contract is implicit, so the
428
- // exact JSON shape, the anchor rule, and the suggestion rule are all spelled
429
- // out with types. Consumer guidance is injected BETWEEN the mission framing
430
- // and the procedure — it can widen what the reviewer pays attention to, but
431
- // the machine contract (anchor rule, JSON shape, bounds) is always appended
432
- // by this builder and cannot be edited out.
433
- // ---------------------------------------------------------------------------
434
-
435
- /** Consumer-supplied domain guidance: static lines or a function of the mission. */
436
- export type ReviewGuidance =
437
- | string
438
- | ReadonlyArray<string>
439
- | ((mission: ReviewMission) => string | ReadonlyArray<string>);
440
-
441
- export const resolveGuidance = (
442
- guidance: ReviewGuidance | undefined,
443
- mission: ReviewMission,
444
- ): ReadonlyArray<string> => {
445
- if (guidance === undefined) return [];
446
- const value = typeof guidance === "function" ? guidance(mission) : guidance;
447
- const lines = typeof value === "string" ? [value] : value;
448
- return lines.filter((line) => line.length > 0);
449
- };
450
-
451
- export interface ReviewInstructionOptions {
452
- readonly guidance?: ReviewGuidance | undefined;
453
- /** Advertised findings bound; clamped to the CodeReview schema cap. */
454
- readonly maxFindings?: number | undefined;
455
- }
456
-
457
- /** Clamp a configured findings bound into the schema-supported range. */
458
- export const clampMaxFindings = (maxFindings: number | undefined): number =>
459
- maxFindings === undefined
460
- ? MAX_FINDINGS
461
- : Math.min(MAX_FINDINGS, Math.max(1, Math.trunc(maxFindings)));
462
-
463
- /** Build the flat reviewer's instructions with optional consumer guidance. */
464
- export const makeReviewInstructions =
465
- (options: ReviewInstructionOptions = {}) =>
466
- (mission: ReviewMission): string => {
467
- const maxFindings = clampMaxFindings(options.maxFindings);
468
- return [
469
- `You are a senior code reviewer for pull request #${mission.number} ("${mission.title}") in ${mission.repository}, merging ${mission.headRef} into ${mission.baseRef}. It changes ${mission.changedFileCount} file(s).`,
470
- mission.body.length > 0
471
- ? `Author description:\n${mission.body}`
472
- : "The author provided no description.",
473
- ...resolveGuidance(options.guidance, mission),
474
- ...(mission.adjudicatedContext === undefined || mission.adjudicatedContext.length === 0
475
- ? []
476
- : [
477
- "A maintainer has adjudicated these previously raised review items (disposition, reason). Do not re-raise them unless you have materially new evidence, and if you do, say explicitly what changed since the adjudication:",
478
- ...mission.adjudicatedContext.map((line) => `- ${line}`),
479
- ]),
480
- ...(mission.priorFindingContext === undefined || mission.priorFindingContext.length === 0
481
- ? []
482
- : [
483
- "Your previous review raised these findings on the scope you are re-reviewing. For each, either confirm it still holds, state that it is fixed, or withdraw it; do not demand the opposite of your own prior guidance without explicitly acknowledging the reversal:",
484
- ...mission.priorFindingContext.map((line) => `- ${line}`),
485
- ]),
486
- "Work in this order:",
487
- "1. Call list_changed_files once to see the selected input scope. In incremental reviews it is deliberately a subset of the pull request's full diff (totalFiles counts the whole pull request); omitted paths belong to settled prior scope or explicit host exclusions, not to this run.",
488
- "2. Call read_file_diff for every listed file. A normal diff marks new-version anchors as R<number>; only those numbers are valid startLine/endLine values. When GitHub omitted a diff, the tool may return bounded base/head content marked B/H instead. Review that content, but report its defects as non-anchored concerns because B/H lines cannot anchor GitHub comments. Never anchor a finding to a removed (-), B, or H line.",
489
- "3. Call read_file when you need surrounding context the diff does not show. ONLY listed files are readable — read_file_diff and read_file both return a failed result for any other path (an import, a neighbor, a file named in the description). Do not request or retry unlisted paths; reason from the visible diffs instead and note the gap honestly in your summary when it matters.",
490
- "4. Review for real defects first: correctness, security, concurrency, resource leaks, error handling, API misuse. Style nits are least important. Do not praise; do not restate the diff.",
491
- "When the diff adds or changes a test, check that it can actually fail: a test that would still pass with the bug present is theatre, not coverage. The usual tell is a loose assertion standing where an exact one belongs — >= or a truthiness check over an expected value, or a snapshot that absorbs whatever it is handed.",
492
- "Go shallow only when the diff has no behavioral surface at all: doc typos, formatting, lockfile or generated-code regeneration, a mechanical rename. Line count is not the signal — a one-line change to auth, money, SQL, a comparison operator, or a config default is not trivial.",
493
- "Drop bloat-shaped findings before reporting: defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies, just-in-case guards. A finding must be sound, correct, and worth acting on; prefer an explicit keep over an invented finding.",
494
- '5. After collecting anchored findings, deliberately scan for concerns with NO line to point at: deletion or cleanup plans for code the diff replaces, rollout or migration sequencing, coverage gaps the diff implies but does not add, scope questions only the author can answer. Report each as a "concern", never as a finding with an invented anchor. Every concern must list 1-3 exact changed evidencePaths that support it so later incremental reviews can recheck it when those files change. Report none when none exist, and never split one root concern into differently worded restatements.',
495
- `6. Write a walkthrough: for every file whose evidence you examined, one factual sentence (<= ${MAX_WALKTHROUGH_SUMMARY_CHARS} chars) describing what changed in that file — written for a reader scanning the pull request, never restating the diff line by line. Use only paths from list_changed_files; invented paths are dropped.`,
496
- '7. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"summary": <string, 1-3 paragraphs of overall assessment>, "verdict": <"approve" | "comment" | "request-changes">, "findings": [{"path": <string, a changed file path>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "category": <OPTIONAL: "correctness" | "security" | "concurrency" | "performance" | "resources" | "error-handling" | "testing" | "maintainability" | "style" | "docs">, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], "concerns": <array, OPTIONAL: [{"evidencePaths": <array of 1-3 exact changed file paths>, "severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], only for step-5 concerns with no valid anchor — never duplicate a finding here>, "walkthrough": <array, OPTIONAL: [{"path": <string, a changed file path>, "summary": <string, the step-6 sentence>}], one entry per reviewed file>}.',
497
- `Report at most ${maxFindings} findings and at most ${MAX_CONCERNS} concerns; prefer the most important ones. An empty findings array with verdict "approve" is a valid review. Include "suggestion" only when you are confident the replacement compiles and preserves intent; its text must contain the full replacement for every line in the range and nothing else.`,
498
- 'Use verdict "request-changes" only when at least one finding or concern is "blocking". Line anchors you invent will be discarded, so copy R-numbers from read_file_diff output.',
499
- ].join("\n");
500
- };
501
-
502
- /** The default flat-reviewer instructions: no guidance, schema-cap findings. */
503
- export const reviewInstructions = makeReviewInstructions();
504
-
505
- /** The default flat-reviewer execution bounds. */
506
- export const defaultReviewPolicy = AgentPolicy.make({
507
- maxTurns: 12,
508
- maxToolCalls: 24,
509
- maxDuration: "8 minutes",
510
- toolConcurrency: 2,
511
- // The read tools return refusals as model-visible results (failureMode
512
- // "return"), and a model may probe several out-of-scope paths in ONE
513
- // parallel batch — e.g. files the PR description names outside an
514
- // incremental delta — before it has seen a single refusal. The engine's
515
- // default limit of 3 made that exploration fatal; half the tool-call
516
- // budget keeps the genuinely-stuck brake while maxToolCalls and
517
- // maxDuration bound the run regardless.
518
- repeatedFailureLimit: 12,
519
- tokenBudget: 300_000,
520
- // Keep enough output/summary headroom for the 200k-class provider window;
521
- // tool-heavy histories prune before the engine spends a summarization call.
522
- contextTokenLimit: 150_000,
523
- toolResultBounds: ToolResultBounds.make({ maxBytes: REVIEW_TOOL_RESULT_MAX_BYTES }),
524
- // Budget soft landing (RUN-018): an exhausted reviewer returns its partial
525
- // review on one final tool-free turn instead of failing the whole run.
526
- onExhaustion: "final-answer",
527
- });
528
-
529
- // ---------------------------------------------------------------------------
530
- // Agent Definition: model-agnostic (D-027); bindings are created by callers
531
- // or by the configuration factory.
532
- // ---------------------------------------------------------------------------
533
-
534
- export const PullRequestReviewer = Agent.define("pr-reviewer", {
535
- input: ReviewMission,
536
- output: CodeReview,
537
- instructions: reviewInstructions,
538
- toolkit: ReviewToolkit,
539
- policy: defaultReviewPolicy,
540
- description:
541
- "Review one pull request read-only: list the changeset, read annotated diffs and head-file context, and return a structured, line-anchored code review.",
542
- metadata: { deploymentClass: "E", surface: "read-only" },
543
- });