@effect-agent/pr-review 0.1.0-beta.21 → 0.1.0-beta.23

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.
@@ -1,20 +1,20 @@
1
- import { Effect, Layer, Schema } from "effect";
1
+ import { Effect, Schema } from "effect";
2
2
  import {
3
3
  Agent,
4
4
  AgentPolicy,
5
- Subagent,
6
- SubagentPolicy,
7
- SubagentRuntime,
8
- ToolExecutionClass,
5
+ AgentRuntime,
9
6
  ToolResultBounds,
7
+ type BudgetAdapterError,
8
+ type BudgetExceeded,
9
+ type RunBudgetHook,
10
10
  type RuntimeBinding,
11
11
  } from "effect-agent";
12
- import { Tool, Toolkit } from "effect/unstable/ai";
12
+ import { Toolkit } from "effect/unstable/ai";
13
13
 
14
14
  import { anchorViolation } from "./anchors.ts";
15
- import { ChangedFileStatus, ChangedPath } from "./diff.ts";
15
+ import { boundedListReason, FailedReviewPass, ReviewAssurance } from "./coverage.ts";
16
+ import { ChangedFileStatus, ChangedPath, type ChangedFile } from "./diff.ts";
16
17
  import {
17
- clampMaxFindings,
18
18
  CodeReview,
19
19
  fileReviewEvidenceChunks,
20
20
  MAX_PATCH_CHARS,
@@ -22,7 +22,6 @@ import {
22
22
  REVIEW_TOOL_RESULT_MAX_BYTES,
23
23
  ReviewConcern,
24
24
  ReviewFinding,
25
- ReviewMission,
26
25
  WalkthroughEntry,
27
26
  } from "./review-agent.ts";
28
27
  import {
@@ -31,23 +30,31 @@ import {
31
30
  MAX_UNIT_FILES,
32
31
  findingAnchorInUnitEvidence,
33
32
  planReviewUnits,
33
+ rankAndDedupeConcerns,
34
+ rankAndDedupeFindings,
34
35
  ReviewEvidenceShardId,
35
36
  ReviewPassId,
36
37
  ReviewRiskCategory,
37
38
  ReviewUnitId,
38
- ReviewUnitPlan,
39
+ type ReviewDiscoveryPass,
40
+ type ReviewUnit,
41
+ type ReviewUnitPlan,
39
42
  } from "./review-units.ts";
40
- import { PullRequestSource, PullRequestSourceFailure } from "./source.ts";
41
43
 
42
44
  // ---------------------------------------------------------------------------
43
45
  // The assured fan-out reviewer is a bounded, deterministic three-stage
44
- // pipeline driven through attached S1 children:
46
+ // pipeline scheduled ENTIRELY by host code:
45
47
  //
46
48
  // host plan -> independent discovery passes -> independent verification
47
49
  //
48
- // Host code owns partitioning, risk classification, bounded evidence, exact
49
- // pass settlement, candidate provenance, and the final confirmed-candidate
50
- // fold. The coordinator only schedules the declared work and writes prose.
50
+ // `planReviewUnits` is a pure function, so dispatch is plain Effect structured
51
+ // concurrency over its exact work list there is no coordinator model, no
52
+ // delegation tool, and therefore no prompt-compliance failure mode. A pass
53
+ // that fails (child fault, malformed output) is retried once; a pass that
54
+ // still fails is recorded and its unit's paths are carried forward as
55
+ // retryable unreviewed scope instead of freezing the run's continuity
56
+ // baseline. A finding whose anchor is invalid is discarded and counted —
57
+ // never a reason to reject the whole pass.
51
58
  // ---------------------------------------------------------------------------
52
59
 
53
60
  /** One discovery pass returns at most this many anchored candidates. */
@@ -59,9 +66,16 @@ export const MAX_CHILD_CONCERNS = 3;
59
66
  /** Every unit receives independent general and specialist discovery passes. */
60
67
  export const MAX_UNIT_CANDIDATES = (MAX_CHILD_FINDINGS + MAX_CHILD_CONCERNS) * 2;
61
68
 
62
- /** General + specialist discovery for every unit, then one verifier per unit. */
69
+ /**
70
+ * General + specialist discovery for every unit, then one verifier per unit.
71
+ * The one-retry budget doubles the worst-case child Run count, but the
72
+ * schedule itself never exceeds this bound.
73
+ */
63
74
  export const MAX_REVIEW_CHILDREN = MAX_REVIEW_UNITS * 3;
64
75
 
76
+ /** Bounded structured concurrency across units; passes inside a unit are sequential. */
77
+ export const REVIEW_UNIT_CONCURRENCY = 4;
78
+
65
79
  /** Structural minimum for a child that exposes no tools. */
66
80
  export const MAX_FILE_REVIEW_TOOL_CALLS = 1;
67
81
 
@@ -111,9 +125,51 @@ export class CandidateAssessment extends Schema.Class<CandidateAssessment>(
111
125
  )({
112
126
  candidateId: ReviewCandidateId,
113
127
  disposition: Schema.Literals(["confirmed", "rejected"]),
128
+ /**
129
+ * Exact suggestion settlement: required when the candidate finding carries
130
+ * a suggestion, forbidden otherwise. Untrusted child output cannot publish
131
+ * a GitHub replacement block by prompt compliance alone — the host keeps a
132
+ * confirmed finding's suggestion only on an exact "committable" settlement.
133
+ */
134
+ suggestion: Schema.optionalKey(
135
+ Schema.Literals(["committable", "not-committable"]).annotate({
136
+ description:
137
+ 'Required exactly when the candidate finding carries a suggestion: "committable" only when its text is the full replacement source for exactly lines startLine..endLine and nothing else. Forbidden for candidates without a suggestion.',
138
+ }),
139
+ ),
114
140
  rationale: Schema.NonEmptyString.check(Schema.isMaxLength(600)),
115
141
  }) {}
116
142
 
143
+ /**
144
+ * Exact suggestion settlement shape: a carried suggestion must be settled and
145
+ * nothing else may be. A verification report that violates it is treated as a
146
+ * misbehaving pass and retried within the pass budget.
147
+ */
148
+ export const assessmentSettlesSuggestionExactly = (
149
+ assessment: CandidateAssessment,
150
+ candidate: ReviewCandidate,
151
+ ): boolean =>
152
+ candidate._tag === "FindingCandidate" && candidate.finding.suggestion !== undefined
153
+ ? assessment.suggestion !== undefined
154
+ : assessment.suggestion === undefined;
155
+
156
+ /**
157
+ * Fail-closed publication of a confirmed finding: only an exact "committable"
158
+ * settlement keeps the suggestion; anything else publishes the finding with
159
+ * the suggestion stripped so unverified text can never become a one-click
160
+ * GitHub replacement block.
161
+ */
162
+ export const confirmedFindingForPublication = (
163
+ assessment: CandidateAssessment,
164
+ candidate: FindingCandidate,
165
+ ): ReviewFinding => {
166
+ if (candidate.finding.suggestion === undefined || assessment.suggestion === "committable") {
167
+ return candidate.finding;
168
+ }
169
+ const { suggestion: _stripped, ...finding } = candidate.finding;
170
+ return ReviewFinding.make(finding);
171
+ };
172
+
117
173
  /**
118
174
  * Concern candidates need explicit paths internally to bind the claim to
119
175
  * scheduled evidence. The verifier receives the complete bounded unit so it
@@ -139,21 +195,6 @@ const EvidenceShardIds = Schema.Array(ReviewEvidenceShardId)
139
195
  .check(Schema.isMinLength(1))
140
196
  .check(Schema.isMaxLength(MAX_UNIT_EVIDENCE_SHARDS));
141
197
 
142
- /** Strict-object coordinator request for either discovery or verification. */
143
- export class FileReviewRequest extends Schema.Class<FileReviewRequest>(
144
- "@effect-agent/pr-review/FileReviewRequest",
145
- )({
146
- phase: ReviewWorkPhase,
147
- workId: ReviewPassId,
148
- unitId: ReviewUnitId,
149
- paths: UnitPaths,
150
- evidenceShardIds: EvidenceShardIds,
151
- perspective: ReviewWorkPerspective,
152
- riskCategories: RiskCategories,
153
- /** Empty for discovery; the exact discovered set for unit verification. */
154
- candidates: Candidates,
155
- }) {}
156
-
157
198
  /** One complete host-selected evidence shard supplied to a review child. */
158
199
  export class FileReviewEvidence extends Schema.Class<FileReviewEvidence>(
159
200
  "@effect-agent/pr-review/FileReviewEvidence",
@@ -178,6 +219,7 @@ export class FileReviewBrief extends Schema.Class<FileReviewBrief>(
178
219
  evidenceShardIds: EvidenceShardIds,
179
220
  perspective: ReviewWorkPerspective,
180
221
  riskCategories: RiskCategories,
222
+ /** Empty for discovery; the exact discovered set for unit verification. */
181
223
  candidates: Candidates,
182
224
  evidence: Schema.Array(FileReviewEvidence)
183
225
  .check(Schema.isMinLength(1))
@@ -197,36 +239,20 @@ export class FileReviewReport extends Schema.Class<FileReviewReport>(
197
239
  assessments: Schema.Array(CandidateAssessment).check(Schema.isMaxLength(MAX_UNIT_CANDIDATES)),
198
240
  }) {}
199
241
 
200
- /** Bounded coordinator-visible result with host-assigned candidate IDs. */
201
- export class FileReviewUnitResult extends Schema.Class<FileReviewUnitResult>(
202
- "@effect-agent/pr-review/FileReviewUnitResult",
203
- )({
204
- phase: ReviewWorkPhase,
205
- workId: ReviewPassId,
206
- unitId: ReviewUnitId,
207
- candidates: Candidates,
208
- fileSummaries: Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
209
- assessments: Schema.Array(CandidateAssessment).check(Schema.isMaxLength(MAX_UNIT_CANDIDATES)),
210
- }) {}
211
-
212
- export class FileReviewUnitFailed extends Schema.TaggedError<FileReviewUnitFailed>()(
213
- "FileReviewUnitFailed",
214
- {
215
- childErrorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
216
- message: Schema.String.check(Schema.isMaxLength(400)),
217
- },
218
- ) {}
219
-
220
- export class FileReviewWorkRejected extends Schema.TaggedError<FileReviewWorkRejected>()(
221
- "FileReviewWorkRejected",
242
+ /**
243
+ * A structurally valid child report that does not answer the scheduled pass:
244
+ * wrong identity, phase-inapplicable fields, or an inexact assessment set.
245
+ * Retried once like any other pass fault, because it is model misbehavior,
246
+ * not evidence about the code under review.
247
+ */
248
+ export class ReviewPassMisbehaved extends Schema.TaggedError<ReviewPassMisbehaved>()(
249
+ "ReviewPassMisbehaved",
222
250
  {
223
251
  workId: ReviewPassId,
224
252
  reason: Schema.NonEmptyString.check(Schema.isMaxLength(600)),
225
253
  },
226
254
  ) {}
227
255
 
228
- export const FileReviewFailure = Schema.Union([FileReviewUnitFailed, FileReviewWorkRejected]);
229
-
230
256
  export interface FanOutInstructionOptions {
231
257
  readonly guidance?: string | ReadonlyArray<string> | undefined;
232
258
  }
@@ -260,7 +286,8 @@ export const makeFileReviewerInstructions =
260
286
  "Independently verify every candidate in the input. You did not receive another reviewer's transcript or reasoning; use only the candidate claim and bounded evidence.",
261
287
  "The evidence array contains the complete bounded unit, including neighboring changed code that may confirm or falsify a locally plausible claim.",
262
288
  "For each candidate, try to falsify it first. Confirm only when the cited behavior is supported and actionable. Reject unsupported, speculative, duplicate, or non-actionable candidates.",
263
- 'Return ONLY JSON with phase "verification", the exact workId/unitId, empty findings/concerns/fileSummaries arrays, and exactly one assessment per candidateId. Each assessment is {"candidateId": <exact id>, "disposition": <"confirmed" | "rejected">, "rationale": <bounded evidence-based reason>}. Never add or omit an id.',
289
+ 'Return ONLY JSON with phase "verification", the exact workId/unitId, empty findings/concerns/fileSummaries arrays, and exactly one assessment per candidateId. Each assessment is {"candidateId": <exact id>, "disposition": <"confirmed" | "rejected">, "suggestion": <"committable" | "not-committable", present exactly when the candidate finding carries a suggestion>, "rationale": <bounded evidence-based reason>}. Never add or omit an id.',
290
+ 'Settle every carried suggestion independently of the claim: answer "committable" only when its text is the full replacement source for exactly lines startLine..endLine and nothing else — it compiles in context and preserves the finding\'s intent, never prose describing a change. Otherwise answer "not-committable"; the host then publishes the confirmed finding without its suggestion. Omit the assessment "suggestion" field for candidates without one.',
264
291
  ].join("\n");
265
292
  }
266
293
  const focus =
@@ -275,6 +302,8 @@ export const makeFileReviewerInstructions =
275
302
  "The discovery evidence array contains every complete shard in the unit. Review every entry and every shard of a multi-shard path. A later independent verifier, not you, decides which candidates publish.",
276
303
  "When a non-anchored concern depends on one or more unit files, list 1-3 exact evidencePaths to bind the claim to scheduled evidence.",
277
304
  `Return ONLY JSON with phase "discovery", the exact workId/unitId, up to ${MAX_CHILD_FINDINGS} findings, up to ${MAX_CHILD_CONCERNS} concerns shaped as {concern, evidencePaths}, one factual file summary per path (<= ${MAX_WALKTHROUGH_SUMMARY_CHARS} chars), and an empty assessments array. Empty candidate arrays are valid; do not invent defects.`,
305
+ 'Each finding is {"path": <a unit file path>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "category": <OPTIONAL problem-kind label>, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement source code for exactly lines startLine..endLine, ready to commit>}.',
306
+ 'Include "suggestion" only when you are confident the replacement compiles and preserves intent; its text must contain the full replacement source for every line in the range and nothing else — never prose describing the change, which belongs in "body".',
278
307
  ].join("\n");
279
308
  };
280
309
 
@@ -282,9 +311,6 @@ export const fileReviewerInstructions = makeFileReviewerInstructions();
282
311
 
283
312
  export const FileReviewToolkit = Toolkit.empty;
284
313
 
285
- /** Compatibility export: the evidence-only child has no handler requirements. */
286
- export const FileReviewToolkitLayer = Layer.empty;
287
-
288
314
  export const defaultFileReviewerPolicy = AgentPolicy.make({
289
315
  maxTurns: 6,
290
316
  maxToolCalls: MAX_FILE_REVIEW_TOOL_CALLS,
@@ -299,121 +325,74 @@ export const defaultFileReviewerPolicy = AgentPolicy.make({
299
325
  onExhaustion: "fail",
300
326
  });
301
327
 
302
- export const fileReviewPolicy = SubagentPolicy.make({
303
- maxChildren: MAX_REVIEW_CHILDREN,
304
- maxConcurrency: 4,
305
- maxTurns: 6,
306
- maxToolCalls: MAX_FILE_REVIEW_TOOL_CALLS,
307
- maxDuration: "6 minutes",
308
- maxResultBytes: 256 * 1024,
309
- });
310
-
311
- export const mapFileReviewChildFailure = (failure: {
312
- readonly _tag: string;
313
- readonly message?: string;
314
- }): FileReviewUnitFailed =>
315
- FileReviewUnitFailed.make({
316
- childErrorTag: failure._tag,
317
- message: (failure.message ?? "").slice(0, 400),
328
+ export const makeFileReviewerDefinition = (options: FanOutInstructionOptions = {}) =>
329
+ Agent.define("pr-review-worker", {
330
+ input: FileReviewBrief,
331
+ output: FileReviewReport,
332
+ instructions: makeFileReviewerInstructions(options),
333
+ toolkit: FileReviewToolkit,
334
+ policy: defaultFileReviewerPolicy,
335
+ description:
336
+ "Perform one bounded discovery or independent candidate-verification pass over host-supplied pull-request evidence.",
337
+ metadata: { deploymentClass: "E", surface: "read-only", stage: "discovery-verification" },
318
338
  });
319
339
 
320
- const sameStrings = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
321
- left.length === right.length && left.every((value, index) => value === right[index]);
340
+ export const FileReviewer = makeFileReviewerDefinition();
322
341
 
323
- const rejectWork = (workId: string, reason: string) =>
324
- FileReviewWorkRejected.make({ workId, reason });
342
+ /** The exact child binding shape the host pipeline schedules. */
343
+ export type FileReviewerBinding<Provider, ModelProvides, ModelRequires> = RuntimeBinding<
344
+ typeof FileReviewBrief,
345
+ typeof FileReviewReport,
346
+ ReturnType<typeof makeFileReviewerInstructions>,
347
+ Toolkit.Tools<typeof FileReviewToolkit>,
348
+ Provider,
349
+ ModelProvides,
350
+ ModelRequires
351
+ >;
325
352
 
326
- /** Validate coordinator scheduling against the current deterministic plan. */
327
- const prepareReviewBrief = (request: FileReviewRequest) =>
328
- Effect.gen(function* () {
329
- const source = yield* PullRequestSource;
330
- const mapSourceFailure = (failure: PullRequestSourceFailure) =>
331
- rejectWork(
332
- request.workId,
333
- `pull-request source ${failure.operation} failed: ${failure.reason}`.slice(0, 600),
334
- );
335
- const files = yield* source.changedFiles.pipe(Effect.mapError(mapSourceFailure));
336
- const metadata = yield* source.metadata.pipe(Effect.mapError(mapSourceFailure));
337
- const plan = planReviewUnits(files, { totalChangedFiles: metadata.totalChangedFiles });
338
- const unit = plan.units.find((candidate) => candidate.unitId === request.unitId);
339
- if (
340
- unit === undefined ||
341
- !sameStrings(request.paths, unit.paths) ||
342
- !sameStrings(
343
- request.evidenceShardIds,
344
- unit.evidenceShards.map((shard) => shard.shardId),
345
- )
346
- ) {
347
- return yield* rejectWork(request.workId, "request does not match a host-planned unit");
348
- }
353
+ // ---------------------------------------------------------------------------
354
+ // Host pipeline.
355
+ // ---------------------------------------------------------------------------
349
356
 
350
- if (request.phase === "discovery") {
351
- const pass = plan.discoveryPasses.find((candidate) => candidate.passId === request.workId);
352
- if (
353
- pass === undefined ||
354
- pass.unitId !== request.unitId ||
355
- !sameStrings(pass.paths, request.paths) ||
356
- !sameStrings(pass.evidenceShardIds, request.evidenceShardIds) ||
357
- pass.perspective !== request.perspective ||
358
- !sameStrings(pass.riskCategories, request.riskCategories) ||
359
- request.candidates.length !== 0
360
- ) {
361
- return yield* rejectWork(request.workId, "discovery request does not match the host plan");
362
- }
363
- } else {
364
- if (
365
- request.workId !== `${request.unitId}-verification` ||
366
- request.perspective !== "candidate-verification" ||
367
- !sameStrings(request.riskCategories, unit.riskCategories) ||
368
- request.candidates.length === 0
369
- ) {
370
- return yield* rejectWork(
371
- request.workId,
372
- "verification request does not match the host-planned unit",
373
- );
374
- }
375
- const candidateIds = new Set<string>();
376
- const candidateSubjects = new Set<string>();
377
- const allowed = new Set(unit.paths);
378
- for (const candidate of request.candidates) {
379
- const subjectKey = reviewCandidateSubjectKey(candidate);
380
- if (
381
- candidateIds.has(candidate.candidateId) ||
382
- candidateSubjects.has(subjectKey) ||
383
- candidate.unitId !== unit.unitId ||
384
- candidate.evidencePaths.some((path) => !allowed.has(path)) ||
385
- (candidate._tag === "FindingCandidate" && !allowed.has(candidate.finding.path))
386
- ) {
387
- return yield* rejectWork(
388
- request.workId,
389
- "verification candidates are duplicated or outside the planned unit",
390
- );
391
- }
392
- candidateIds.add(candidate.candidateId);
393
- candidateSubjects.add(subjectKey);
394
- }
395
- }
357
+ /** Everything one settled fan-out pipeline run produced, before publication. */
358
+ export interface FanOutPipelineOutcome {
359
+ readonly review: CodeReview;
360
+ readonly assurance: ReviewAssurance;
361
+ readonly plan: ReviewUnitPlan;
362
+ /** Paths of units with an unsettled pass — retryable scope for the next run. */
363
+ readonly unreviewedPaths: ReadonlyArray<string>;
364
+ /** Total settled child turns across every scheduled pass. */
365
+ readonly turns: number;
366
+ }
367
+
368
+ export interface FanOutPipelineInput {
369
+ readonly files: ReadonlyArray<ChangedFile>;
370
+ readonly anchorFiles: ReadonlyArray<ChangedFile>;
371
+ readonly totalChangedFiles: number;
372
+ readonly maxFindings?: number | undefined;
373
+ /** Shared run budget observed by every child pass. */
374
+ readonly budget?: RunBudgetHook<BudgetExceeded | BudgetAdapterError> | undefined;
375
+ }
376
+
377
+ const candidateOrdinal = (index: number): string => String(index + 1).padStart(3, "0");
396
378
 
379
+ /** Rebuild one unit's complete evidence from the same snapshot the plan used. */
380
+ const unitEvidence = (
381
+ unit: ReviewUnit,
382
+ files: ReadonlyArray<ChangedFile>,
383
+ ): Effect.Effect<ReadonlyArray<FileReviewEvidence>> =>
384
+ Effect.gen(function* () {
397
385
  const byPath = new Map(files.map((file) => [file.path, file] as const));
398
386
  const evidence: Array<FileReviewEvidence> = [];
399
387
  for (const shard of unit.evidenceShards) {
400
388
  const file = byPath.get(shard.path);
401
- if (file === undefined) {
402
- return yield* rejectWork(
403
- request.workId,
404
- `planned evidence path is unavailable: ${shard.path}`,
405
- );
406
- }
407
- const chunks = fileReviewEvidenceChunks(file);
408
- const chunk = chunks[shard.ordinal - 1];
409
- if (
410
- chunk === undefined ||
411
- chunks.length !== shard.total ||
412
- chunk.annotatedPatch.length !== shard.evidenceChars
413
- ) {
414
- return yield* rejectWork(
415
- request.workId,
416
- `planned evidence shard no longer matches source: ${shard.shardId}`,
389
+ const chunk =
390
+ file === undefined ? undefined : fileReviewEvidenceChunks(file)[shard.ordinal - 1];
391
+ if (file === undefined || chunk === undefined) {
392
+ // The plan and this evidence derive from the same immutable snapshot
393
+ // via the same pure function; a mismatch is a host defect, not input.
394
+ return yield* Effect.die(
395
+ new Error(`planned evidence shard has no source: ${shard.shardId} (${shard.path})`),
417
396
  );
418
397
  }
419
398
  evidence.push(
@@ -428,292 +407,464 @@ const prepareReviewBrief = (request: FileReviewRequest) =>
428
407
  }),
429
408
  );
430
409
  }
431
- return FileReviewBrief.make({ ...request, evidence });
410
+ return evidence;
432
411
  });
433
412
 
434
- const candidateOrdinal = (index: number): string => String(index + 1).padStart(3, "0");
413
+ interface SettledPass {
414
+ readonly report: FileReviewReport;
415
+ readonly turns: number;
416
+ }
417
+
418
+ type PassOutcome =
419
+ | ({ readonly _tag: "settled" } & SettledPass)
420
+ | { readonly _tag: "failed"; readonly errorTag: string };
435
421
 
436
- const projectReviewResult = (
422
+ const misbehaved = (workId: string, reason: string) =>
423
+ ReviewPassMisbehaved.make({ workId, reason: reason.slice(0, 600) });
424
+
425
+ /** Validate that a verification report assesses exactly the scheduled candidates. */
426
+ const validateVerificationReport = (
427
+ brief: FileReviewBrief,
437
428
  report: FileReviewReport,
438
- context: { readonly budgetExhausted: boolean },
439
- request: FileReviewRequest,
440
- ) => {
441
- if (context.budgetExhausted) {
442
- return Effect.fail(
443
- rejectWork(report.workId, "review work exhausted its budget before exact settlement"),
444
- );
429
+ ): ReviewPassMisbehaved | undefined => {
430
+ if (report.findings.length > 0 || report.concerns.length > 0 || report.fileSummaries.length > 0) {
431
+ return misbehaved(brief.workId, "verification output contained discovery-only fields");
445
432
  }
446
- if (
447
- report.phase !== request.phase ||
448
- report.workId !== request.workId ||
449
- report.unitId !== request.unitId
450
- ) {
451
- return Effect.fail(
452
- rejectWork(request.workId, "review output identity does not match the scheduled request"),
453
- );
433
+ const expectedById = new Map(
434
+ brief.candidates.map((candidate) => [candidate.candidateId, candidate] as const),
435
+ );
436
+ const assessedIds = new Set<string>();
437
+ for (const assessment of report.assessments) {
438
+ const candidate = expectedById.get(assessment.candidateId);
439
+ if (candidate === undefined || assessedIds.has(assessment.candidateId)) {
440
+ return misbehaved(brief.workId, "verification output did not assess the exact candidate set");
441
+ }
442
+ if (!assessmentSettlesSuggestionExactly(assessment, candidate)) {
443
+ return misbehaved(
444
+ brief.workId,
445
+ "verification output did not settle suggestion publication exactly",
446
+ );
447
+ }
448
+ assessedIds.add(assessment.candidateId);
454
449
  }
455
- if (report.phase === "verification") {
450
+ if (assessedIds.size !== expectedById.size) {
451
+ return misbehaved(brief.workId, "verification output did not assess the exact candidate set");
452
+ }
453
+ return undefined;
454
+ };
455
+
456
+ /**
457
+ * Run one scheduled pass: execute the child, decode its report, and enforce
458
+ * the pass contract. Any typed fault — child failure, malformed or misdirected
459
+ * output — is retried once; budget exhaustion is terminal because a retry
460
+ * would fail the same way. The settled outcome is a value either way, so one
461
+ * flaky pass can never fail the whole pipeline.
462
+ */
463
+ const runReviewPass = <Provider, ModelProvides, ModelRequires>(
464
+ binding: FileReviewerBinding<Provider, ModelProvides, ModelRequires>,
465
+ brief: FileReviewBrief,
466
+ budget: RunBudgetHook<BudgetExceeded | BudgetAdapterError> | undefined,
467
+ ) =>
468
+ Effect.gen(function* () {
469
+ const result = yield* AgentRuntime.run(binding, brief, {
470
+ ...(budget === undefined ? {} : { budget }),
471
+ estimateCostMicrousd: () => Effect.succeed(500),
472
+ });
473
+ const report = yield* Schema.decodeUnknownEffect(FileReviewReport)(result.output).pipe(
474
+ Effect.mapError((error) =>
475
+ misbehaved(brief.workId, `child report failed to decode: ${error.message}`),
476
+ ),
477
+ );
456
478
  if (
457
- report.findings.length > 0 ||
458
- report.concerns.length > 0 ||
459
- report.fileSummaries.length > 0
479
+ report.phase !== brief.phase ||
480
+ report.workId !== brief.workId ||
481
+ report.unitId !== brief.unitId
460
482
  ) {
461
- return Effect.fail(
462
- rejectWork(report.workId, "verification output contained discovery-only fields"),
483
+ return yield* misbehaved(
484
+ brief.workId,
485
+ "child report identity does not match the scheduled pass",
463
486
  );
464
487
  }
465
- const expectedIds = new Set(request.candidates.map((candidate) => candidate.candidateId));
466
- const assessedIds = new Set<string>();
467
- for (const assessment of report.assessments) {
468
- if (!expectedIds.has(assessment.candidateId) || assessedIds.has(assessment.candidateId)) {
469
- return Effect.fail(
470
- rejectWork(report.workId, "verification output did not assess the exact candidate set"),
488
+ if (brief.phase === "verification") {
489
+ const violation = validateVerificationReport(brief, report);
490
+ if (violation !== undefined) return yield* violation;
491
+ } else if (report.assessments.length > 0) {
492
+ return yield* misbehaved(
493
+ brief.workId,
494
+ "discovery output contained verification-only assessments",
495
+ );
496
+ }
497
+ return { report, turns: result.turns } satisfies SettledPass;
498
+ }).pipe(
499
+ Effect.scoped,
500
+ Effect.retry({ times: 1, while: (error) => error._tag !== "BudgetExceeded" }),
501
+ Effect.map((settled): PassOutcome => ({ _tag: "settled", ...settled })),
502
+ Effect.catch((error) =>
503
+ Effect.succeed<PassOutcome>({ _tag: "failed", errorTag: String(error._tag).slice(0, 256) }),
504
+ ),
505
+ );
506
+
507
+ interface DiscoveryHarvest {
508
+ readonly candidates: ReadonlyArray<ReviewCandidate>;
509
+ readonly fileSummaries: ReadonlyArray<WalkthroughEntry>;
510
+ readonly discarded: number;
511
+ }
512
+
513
+ /**
514
+ * Keep only findings anchored inside the pass's exact assigned evidence and
515
+ * concerns bound to unit paths. Everything else is discarded and counted —
516
+ * an invalid anchor invalidates one claim, never the pass that produced it.
517
+ */
518
+ const harvestDiscovery = (
519
+ pass: ReviewDiscoveryPass,
520
+ unit: ReviewUnit,
521
+ files: ReadonlyArray<ChangedFile>,
522
+ anchorFiles: ReadonlyArray<ChangedFile>,
523
+ report: FileReviewReport,
524
+ ): DiscoveryHarvest => {
525
+ const allowed = new Set(pass.paths);
526
+ let discarded = 0;
527
+ const keptFindings: Array<ReviewFinding> = [];
528
+ for (const finding of report.findings) {
529
+ if (
530
+ !allowed.has(finding.path) ||
531
+ anchorViolation(finding, anchorFiles) !== undefined ||
532
+ !findingAnchorInUnitEvidence(finding, unit, files)
533
+ ) {
534
+ discarded += 1;
535
+ continue;
536
+ }
537
+ keptFindings.push(finding);
538
+ }
539
+ const keptConcerns: Array<DiscoveredConcern> = [];
540
+ for (const candidate of report.concerns) {
541
+ if (candidate.evidencePaths.some((path) => !allowed.has(path))) {
542
+ discarded += 1;
543
+ continue;
544
+ }
545
+ keptConcerns.push(candidate);
546
+ }
547
+ return {
548
+ candidates: [
549
+ ...keptFindings.map((finding, index) =>
550
+ FindingCandidate.make({
551
+ candidateId: `${pass.passId}:finding:${candidateOrdinal(index)}`,
552
+ workId: pass.passId,
553
+ unitId: pass.unitId,
554
+ finding,
555
+ evidencePaths: [finding.path],
556
+ }),
557
+ ),
558
+ ...keptConcerns.map((candidate, index) =>
559
+ ConcernCandidate.make({
560
+ candidateId: `${pass.passId}:concern:${candidateOrdinal(index)}`,
561
+ workId: pass.passId,
562
+ unitId: pass.unitId,
563
+ concern: candidate.concern,
564
+ evidencePaths: candidate.evidencePaths,
565
+ }),
566
+ ),
567
+ ],
568
+ fileSummaries: report.fileSummaries.filter((entry) => allowed.has(entry.path)),
569
+ discarded,
570
+ };
571
+ };
572
+
573
+ interface UnitReviewOutcome {
574
+ readonly failedPasses: ReadonlyArray<FailedReviewPass>;
575
+ readonly discoveredCandidates: number;
576
+ readonly confirmed: ReadonlyArray<{
577
+ readonly assessment: CandidateAssessment;
578
+ readonly candidate: ReviewCandidate;
579
+ }>;
580
+ readonly rejectedCandidates: number;
581
+ readonly unsettledCandidates: number;
582
+ readonly discardedFindings: number;
583
+ readonly walkthrough: ReadonlyArray<WalkthroughEntry>;
584
+ readonly turns: number;
585
+ readonly completedGeneralPasses: number;
586
+ readonly completedSpecialistPasses: number;
587
+ readonly requiredVerificationPasses: number;
588
+ readonly completedVerificationPasses: number;
589
+ readonly unreviewedPaths: ReadonlyArray<string>;
590
+ }
591
+
592
+ const reviewUnit = <Provider, ModelProvides, ModelRequires>(
593
+ binding: FileReviewerBinding<Provider, ModelProvides, ModelRequires>,
594
+ unit: ReviewUnit,
595
+ passes: ReadonlyArray<ReviewDiscoveryPass>,
596
+ input: FanOutPipelineInput,
597
+ ) =>
598
+ Effect.gen(function* () {
599
+ const evidence = yield* unitEvidence(unit, input.files);
600
+ const failedPasses: Array<FailedReviewPass> = [];
601
+ const candidates: Array<ReviewCandidate> = [];
602
+ const subjects = new Set<string>();
603
+ const walkthrough: Array<WalkthroughEntry> = [];
604
+ let discardedFindings = 0;
605
+ let turns = 0;
606
+ let completedGeneralPasses = 0;
607
+ let completedSpecialistPasses = 0;
608
+ for (const pass of passes) {
609
+ const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
610
+ const brief = FileReviewBrief.make({
611
+ phase: "discovery",
612
+ workId: pass.passId,
613
+ unitId: pass.unitId,
614
+ paths: pass.paths,
615
+ evidenceShardIds: pass.evidenceShardIds,
616
+ perspective: pass.perspective,
617
+ riskCategories: pass.riskCategories,
618
+ candidates: [],
619
+ evidence,
620
+ });
621
+ const outcome = yield* runReviewPass(binding, brief, input.budget);
622
+ if (outcome._tag === "failed") {
623
+ failedPasses.push(
624
+ FailedReviewPass.make({ workId: pass.passId, stage, errorTag: outcome.errorTag }),
471
625
  );
626
+ continue;
627
+ }
628
+ turns += outcome.turns;
629
+ if (stage === "specialist") {
630
+ completedSpecialistPasses += 1;
631
+ } else {
632
+ completedGeneralPasses += 1;
633
+ }
634
+ const harvest = harvestDiscovery(pass, unit, input.files, input.anchorFiles, outcome.report);
635
+ discardedFindings += harvest.discarded;
636
+ if (pass.perspective === "general") walkthrough.push(...harvest.fileSummaries);
637
+ for (const candidate of harvest.candidates) {
638
+ const subject = reviewCandidateSubjectKey(candidate);
639
+ if (subjects.has(subject)) continue;
640
+ subjects.add(subject);
641
+ candidates.push(candidate);
472
642
  }
473
- assessedIds.add(assessment.candidateId);
474
643
  }
475
- if (assessedIds.size !== expectedIds.size) {
476
- return Effect.fail(
477
- rejectWork(report.workId, "verification output did not assess the exact candidate set"),
478
- );
644
+ const confirmed: Array<{
645
+ readonly assessment: CandidateAssessment;
646
+ readonly candidate: ReviewCandidate;
647
+ }> = [];
648
+ let rejectedCandidates = 0;
649
+ let unsettledCandidates = 0;
650
+ let completedVerificationPasses = 0;
651
+ const requiredVerificationPasses = candidates.length > 0 ? 1 : 0;
652
+ if (candidates.length > 0) {
653
+ const workId = `${unit.unitId}-verification`;
654
+ const brief = FileReviewBrief.make({
655
+ phase: "verification",
656
+ workId,
657
+ unitId: unit.unitId,
658
+ paths: unit.paths,
659
+ evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
660
+ perspective: "candidate-verification",
661
+ riskCategories: unit.riskCategories,
662
+ candidates,
663
+ evidence,
664
+ });
665
+ const outcome = yield* runReviewPass(binding, brief, input.budget);
666
+ if (outcome._tag === "failed") {
667
+ unsettledCandidates = candidates.length;
668
+ failedPasses.push(
669
+ FailedReviewPass.make({ workId, stage: "verification", errorTag: outcome.errorTag }),
670
+ );
671
+ } else {
672
+ turns += outcome.turns;
673
+ completedVerificationPasses = 1;
674
+ const byId = new Map(candidates.map((candidate) => [candidate.candidateId, candidate]));
675
+ for (const assessment of outcome.report.assessments) {
676
+ const candidate = byId.get(assessment.candidateId);
677
+ if (candidate === undefined) continue;
678
+ if (assessment.disposition === "confirmed") {
679
+ confirmed.push({ assessment, candidate });
680
+ } else {
681
+ rejectedCandidates += 1;
682
+ }
683
+ }
684
+ }
479
685
  }
480
- return Effect.succeed(
481
- FileReviewUnitResult.make({
482
- phase: report.phase,
483
- workId: report.workId,
484
- unitId: report.unitId,
485
- candidates: [],
486
- fileSummaries: [],
487
- assessments: report.assessments,
488
- }),
686
+ return {
687
+ failedPasses,
688
+ discoveredCandidates: candidates.length,
689
+ confirmed,
690
+ rejectedCandidates,
691
+ unsettledCandidates,
692
+ discardedFindings,
693
+ walkthrough,
694
+ turns,
695
+ completedGeneralPasses,
696
+ completedSpecialistPasses,
697
+ requiredVerificationPasses,
698
+ completedVerificationPasses,
699
+ unreviewedPaths: failedPasses.length > 0 ? unit.paths : [],
700
+ } satisfies UnitReviewOutcome;
701
+ });
702
+
703
+ const countNoun = (count: number, noun: string): string =>
704
+ `${count} ${noun}${count === 1 ? "" : "s"}`;
705
+
706
+ const composeSummary = (plan: ReviewUnitPlan, assurance: ReviewAssurance): string => {
707
+ const requiredDiscovery =
708
+ assurance.requiredGeneralDiscoveryPasses + assurance.requiredSpecialistPasses;
709
+ const completedDiscovery =
710
+ assurance.completedGeneralDiscoveryPasses + assurance.completedSpecialistPasses;
711
+ const parts = [
712
+ `Reviewed ${countNoun(plan.totalFiles, "changed file")} across ${countNoun(plan.units.length, "bounded unit")}: ${completedDiscovery}/${requiredDiscovery} discovery and ${assurance.completedVerificationPasses}/${assurance.requiredVerificationPasses} verification pass(es) settled; ${assurance.confirmedCandidates} of ${countNoun(assurance.discoveredCandidates, "discovered candidate")} confirmed by independent verification.`,
713
+ ];
714
+ if (assurance.failedPasses.length > 0) {
715
+ parts.push(
716
+ `${countNoun(assurance.failedPasses.length, "pass")} did not settle; the affected paths are carried forward and retried on the next run. This is a reviewer-side gap, not a code defect.`,
717
+ );
718
+ }
719
+ if (assurance.discardedInvalidFindings > 0) {
720
+ parts.push(
721
+ `${countNoun(assurance.discardedInvalidFindings, "candidate")} discarded for anchors or paths outside the assigned evidence.`,
489
722
  );
490
723
  }
491
- if (report.assessments.length > 0) {
492
- return Effect.fail(
493
- rejectWork(report.workId, "discovery output contained verification-only assessments"),
724
+ if (plan.undiffablePaths.length > 0) {
725
+ parts.push(
726
+ `${countNoun(plan.undiffablePaths.length, "path")} had no reviewable textual evidence and keep input coverage incomplete; exclude such paths with ignore globs when that is intended.`,
494
727
  );
495
728
  }
496
- const allowed = new Set(request.paths);
497
- if (
498
- report.findings.some((finding) => !allowed.has(finding.path)) ||
499
- report.concerns.some((candidate) =>
500
- candidate.evidencePaths.some((path) => !allowed.has(path)),
501
- ) ||
502
- report.fileSummaries.some((entry) => !allowed.has(entry.path))
503
- ) {
504
- return Effect.fail(
505
- rejectWork(report.workId, "discovery output referenced evidence outside the scheduled unit"),
729
+ if (plan.unassignedPaths.length > 0 || plan.unassignedEvidenceShardCount > 0) {
730
+ parts.push(
731
+ "The changeset exceeded the bounded fan-out capacity; unassigned scope is reported under input coverage.",
506
732
  );
507
733
  }
508
- return Effect.gen(function* () {
509
- const source = yield* PullRequestSource;
510
- const mapSourceFailure = (failure: PullRequestSourceFailure) =>
511
- rejectWork(
512
- request.workId,
513
- `pull-request source ${failure.operation} failed: ${failure.reason}`.slice(0, 600),
734
+ parts.push(
735
+ "No configured pipeline can prove absence of defects; this describes settled work only.",
736
+ );
737
+ return parts.join(" ").slice(0, 4_000);
738
+ };
739
+
740
+ /**
741
+ * Run the complete host-scheduled fan-out pipeline over one selected
742
+ * changeset snapshot: plan, independent discovery, exact verification, and a
743
+ * deterministic host-composed CodeReview from verifier-confirmed candidates
744
+ * only. The verdict is derived from confirmed severities, never model prose.
745
+ */
746
+ export const runFanOutReview = <Provider, ModelProvides, ModelRequires>(
747
+ binding: FileReviewerBinding<Provider, ModelProvides, ModelRequires>,
748
+ input: FanOutPipelineInput,
749
+ ) =>
750
+ Effect.gen(function* () {
751
+ const plan = planReviewUnits(input.files, { totalChangedFiles: input.totalChangedFiles });
752
+ const passesByUnit = new Map<string, Array<ReviewDiscoveryPass>>();
753
+ for (const pass of plan.discoveryPasses) {
754
+ const passes = passesByUnit.get(pass.unitId) ?? [];
755
+ passes.push(pass);
756
+ passesByUnit.set(pass.unitId, passes);
757
+ }
758
+ const outcomes = yield* Effect.forEach(
759
+ plan.units,
760
+ (unit) => reviewUnit(binding, unit, passesByUnit.get(unit.unitId) ?? [], input),
761
+ { concurrency: REVIEW_UNIT_CONCURRENCY },
762
+ );
763
+ const failedPasses = outcomes.flatMap((outcome) => outcome.failedPasses);
764
+ const unsettledCandidates = outcomes.reduce(
765
+ (total, outcome) => total + outcome.unsettledCandidates,
766
+ 0,
767
+ );
768
+ const reasons: Array<string> = [];
769
+ if (failedPasses.length > 0) {
770
+ reasons.push(
771
+ boundedListReason(
772
+ "configured review passes did not settle",
773
+ failedPasses.map((pass) => `${pass.workId} (${pass.errorTag})`),
774
+ ),
514
775
  );
515
- const files = yield* source.changedFiles.pipe(Effect.mapError(mapSourceFailure));
516
- const metadata = yield* source.metadata.pipe(Effect.mapError(mapSourceFailure));
517
- const anchorFiles = yield* source.anchorFiles.pipe(Effect.mapError(mapSourceFailure));
518
- const unit = planReviewUnits(files, {
519
- totalChangedFiles: metadata.totalChangedFiles,
520
- }).units.find((candidate) => candidate.unitId === request.unitId);
521
- if (unit === undefined) {
522
- return yield* rejectWork(request.workId, "scheduled review unit is no longer available");
523
776
  }
524
- for (const finding of report.findings) {
525
- const violation = anchorViolation(finding, anchorFiles);
526
- if (violation !== undefined || !findingAnchorInUnitEvidence(finding, unit, files)) {
527
- return yield* rejectWork(
528
- request.workId,
529
- `discovery finding has no valid anchor in its assigned evidence: ${violation ?? finding.path}`,
530
- );
531
- }
777
+ if (unsettledCandidates > 0) {
778
+ reasons.push(
779
+ `${unsettledCandidates} discovered candidate(s) did not receive exact verification`,
780
+ );
532
781
  }
533
- const findingCandidates = report.findings.map((finding, index) =>
534
- FindingCandidate.make({
535
- candidateId: `${request.workId}:finding:${candidateOrdinal(index)}`,
536
- workId: request.workId,
537
- unitId: request.unitId,
538
- finding,
539
- evidencePaths: [finding.path],
540
- }),
782
+ const requiredSpecialistPasses = plan.discoveryPasses.filter(
783
+ (pass) => pass.perspective === "risk-specialist",
784
+ ).length;
785
+ const confirmed = outcomes.flatMap((outcome) => outcome.confirmed);
786
+ const assurance = ReviewAssurance.make({
787
+ status: reasons.length === 0 ? "settled" : "incomplete",
788
+ requiredGeneralDiscoveryPasses: plan.discoveryPasses.length - requiredSpecialistPasses,
789
+ completedGeneralDiscoveryPasses: outcomes.reduce(
790
+ (total, outcome) => total + outcome.completedGeneralPasses,
791
+ 0,
792
+ ),
793
+ requiredSpecialistPasses,
794
+ completedSpecialistPasses: outcomes.reduce(
795
+ (total, outcome) => total + outcome.completedSpecialistPasses,
796
+ 0,
797
+ ),
798
+ requiredVerificationPasses: outcomes.reduce(
799
+ (total, outcome) => total + outcome.requiredVerificationPasses,
800
+ 0,
801
+ ),
802
+ completedVerificationPasses: outcomes.reduce(
803
+ (total, outcome) => total + outcome.completedVerificationPasses,
804
+ 0,
805
+ ),
806
+ discoveredCandidates: outcomes.reduce(
807
+ (total, outcome) => total + outcome.discoveredCandidates,
808
+ 0,
809
+ ),
810
+ confirmedCandidates: confirmed.length,
811
+ rejectedCandidates: outcomes.reduce(
812
+ (total, outcome) => total + outcome.rejectedCandidates,
813
+ 0,
814
+ ),
815
+ unsettledCandidates,
816
+ discardedInvalidFindings: outcomes.reduce(
817
+ (total, outcome) => total + outcome.discardedFindings,
818
+ 0,
819
+ ),
820
+ failedPasses,
821
+ reasons,
822
+ });
823
+ const findings = rankAndDedupeFindings(
824
+ confirmed.flatMap(({ assessment, candidate }) =>
825
+ candidate._tag === "FindingCandidate"
826
+ ? [confirmedFindingForPublication(assessment, candidate)]
827
+ : [],
828
+ ),
541
829
  );
542
- const concernCandidates = report.concerns.map((candidate, index) =>
543
- ConcernCandidate.make({
544
- candidateId: `${request.workId}:concern:${candidateOrdinal(index)}`,
545
- workId: request.workId,
546
- unitId: request.unitId,
547
- concern: candidate.concern,
548
- evidencePaths: candidate.evidencePaths,
549
- }),
830
+ const concerns = rankAndDedupeConcerns(
831
+ confirmed.flatMap(({ candidate }) =>
832
+ candidate._tag === "ConcernCandidate" ? [candidate.concern] : [],
833
+ ),
550
834
  );
551
- return FileReviewUnitResult.make({
552
- phase: report.phase,
553
- workId: report.workId,
554
- unitId: report.unitId,
555
- candidates: [...findingCandidates, ...concernCandidates],
556
- fileSummaries: report.fileSummaries,
557
- assessments: [],
835
+ const walkthrough = outcomes.flatMap((outcome) => outcome.walkthrough);
836
+ const blocking =
837
+ findings.some((finding) => finding.severity === "blocking") ||
838
+ concerns.some((concern) => concern.severity === "blocking");
839
+ const review = CodeReview.make({
840
+ summary: composeSummary(plan, assurance),
841
+ verdict: blocking
842
+ ? "request-changes"
843
+ : findings.length > 0 || concerns.length > 0
844
+ ? "comment"
845
+ : "approve",
846
+ findings,
847
+ ...(concerns.length === 0 ? {} : { concerns }),
848
+ ...(walkthrough.length === 0 ? {} : { walkthrough }),
558
849
  });
850
+ return {
851
+ review,
852
+ assurance,
853
+ plan,
854
+ // Everything not fully reviewed this run and still part of the pull
855
+ // request carries forward, so the baseline can advance without ever
856
+ // moving unreviewed scope behind a green check: failed units retry,
857
+ // whole overflow files review in later installments, and partial or
858
+ // undiffable files keep the check fail-closed until they are reviewed,
859
+ // removed, or explicitly ignored.
860
+ unreviewedPaths: [
861
+ ...new Set([
862
+ ...outcomes.flatMap((outcome) => outcome.unreviewedPaths),
863
+ ...plan.unassignedPaths,
864
+ ...plan.partialEvidencePaths,
865
+ ...plan.undiffablePaths,
866
+ ]),
867
+ ].sort(),
868
+ turns: outcomes.reduce((total, outcome) => total + outcome.turns, 0),
869
+ } satisfies FanOutPipelineOutcome;
559
870
  });
560
- };
561
-
562
- const delegationDescription =
563
- "Run exactly one host-planned discovery or candidate-verification child. Copy every plan field and candidate verbatim; never retry failed work.";
564
-
565
- const makeFileReviewDelegation = (child: ReturnType<typeof makeFileReviewerDefinition>) =>
566
- Subagent.define("delegate_file_review", {
567
- description: delegationDescription,
568
- target: child,
569
- parameters: FileReviewRequest,
570
- success: FileReviewUnitResult,
571
- failure: FileReviewFailure,
572
- failureMode: "return",
573
- prepareInput: prepareReviewBrief,
574
- projectResult: projectReviewResult,
575
- policy: fileReviewPolicy,
576
- });
577
-
578
- export class ListReviewUnitsQuery extends Schema.Class<ListReviewUnitsQuery>(
579
- "@effect-agent/pr-review/ListReviewUnitsQuery",
580
- )({
581
- scope: Schema.Literal("all"),
582
- }) {}
583
-
584
- export const ListReviewUnits = Tool.make("list_review_units", {
585
- description:
586
- "List deterministic bounded review units, explicit risk categories, every required discovery pass, and paths the pipeline cannot cover.",
587
- parameters: ListReviewUnitsQuery,
588
- success: ReviewUnitPlan,
589
- failure: PullRequestSourceFailure,
590
- failureMode: "error",
591
- dependencies: [PullRequestSource],
592
- }).annotate(ToolExecutionClass, "readonly");
593
-
594
- export const FanOutCoordinatorToolkit = Toolkit.make(ListReviewUnits);
595
-
596
- export const FanOutCoordinatorToolkitLayer = FanOutCoordinatorToolkit.toLayer({
597
- list_review_units: () =>
598
- Effect.gen(function* () {
599
- const source = yield* PullRequestSource;
600
- const files = yield* source.changedFiles;
601
- const metadata = yield* source.metadata;
602
- return planReviewUnits(files, { totalChangedFiles: metadata.totalChangedFiles });
603
- }),
604
- });
605
-
606
- export const makeFanOutReviewInstructions =
607
- (options: FanOutInstructionOptions & { readonly maxFindings?: number | undefined } = {}) =>
608
- (mission: ReviewMission): string => {
609
- const maxFindings = clampMaxFindings(options.maxFindings);
610
- return [
611
- `You coordinate the bounded multi-pass review of pull request #${mission.number} ("${mission.title}") in ${mission.repository}.`,
612
- mission.body.length > 0 ? `Author description:\n${mission.body}` : "No author description.",
613
- ...staticGuidanceLines(options.guidance),
614
- "1. Call list_review_units exactly once.",
615
- '2. For EVERY discoveryPass, call delegate_file_review exactly once with phase "discovery", workId=passId, and the pass unitId/paths/evidenceShardIds/perspective/riskCategories verbatim; candidates must be []. Prefer one bounded parallel batch. Never retry.',
616
- '3. Group candidates returned by all successful discovery passes by unit. Deterministically deduplicate byte-identical finding or concern payloads, retaining the first candidate in discoveryPass plan order. For every unit with at least one retained candidate, call delegate_file_review exactly once with phase "verification", workId "<unitId>-verification", perspective "candidate-verification", the unit paths/evidenceShardIds/riskCategories, and EVERY retained candidate copied byte-for-byte. Prefer one bounded parallel batch. Never retry.',
617
- "4. Verification is authoritative: rejected candidates must not be reported. The host independently reconstructs publishable findings from exact confirmed assessments, so do not select, rewrite, downgrade, or invent findings.",
618
- `5. Return ONLY CodeReview JSON. Write a concise summary of completed and failed stages. Set findings=[] and concerns=[]; the host injects exact confirmed candidates. Copy factual fileSummaries into walkthrough without invention. The host publication cap is ${maxFindings}.`,
619
- "No configured pipeline can prove absence of defects. Describe settled work, never an exhaustive or defect-free review.",
620
- ].join("\n");
621
- };
622
-
623
- export const fanOutReviewInstructions = makeFanOutReviewInstructions();
624
-
625
- export const defaultFanOutPolicy = AgentPolicy.make({
626
- maxTurns: 7,
627
- maxToolCalls: 1 + MAX_REVIEW_CHILDREN,
628
- maxDuration: "20 minutes",
629
- toolConcurrency: 4,
630
- repeatedFailureLimit: 3,
631
- tokenBudget: 400_000,
632
- contextTokenLimit: 150_000,
633
- // Coordinator exhaustion cannot become an assured result; exact stage
634
- // settlement, not this final prose, determines assurance.
635
- onExhaustion: "final-answer",
636
- });
637
-
638
- export interface FanOutReviewSuite {
639
- readonly child: ReturnType<typeof makeFileReviewerDefinition>;
640
- readonly parent: ReturnType<typeof makeFanOutReviewerDefinition>;
641
- readonly delegation: ReturnType<typeof makeFileReviewDelegation>;
642
- }
643
-
644
- const makeFileReviewerDefinition = (options: FanOutInstructionOptions = {}) =>
645
- Agent.define("pr-review-worker", {
646
- input: FileReviewBrief,
647
- output: FileReviewReport,
648
- instructions: makeFileReviewerInstructions(options),
649
- toolkit: FileReviewToolkit,
650
- policy: defaultFileReviewerPolicy,
651
- description:
652
- "Perform one bounded discovery or independent candidate-verification pass over host-supplied pull-request evidence.",
653
- metadata: { deploymentClass: "E", surface: "read-only", stage: "discovery-verification" },
654
- });
655
-
656
- const delegationToolFor = (delegation: ReturnType<typeof makeFileReviewDelegation>) =>
657
- delegation.tool.annotate(ToolExecutionClass, "readonly");
658
-
659
- const makeFanOutReviewerDefinition = (
660
- options: FanOutInstructionOptions & { readonly maxFindings?: number | undefined },
661
- delegation: ReturnType<typeof makeFileReviewDelegation>,
662
- ) =>
663
- Agent.define("pr-fanout-reviewer", {
664
- input: ReviewMission,
665
- output: CodeReview,
666
- instructions: makeFanOutReviewInstructions(options),
667
- toolkit: Toolkit.make(ListReviewUnits, delegationToolFor(delegation)),
668
- policy: defaultFanOutPolicy,
669
- description:
670
- "Coordinate deterministic general/specialist discovery and independent candidate verification over bounded review units.",
671
- metadata: {
672
- deploymentClass: "E",
673
- surface: "read-only",
674
- delegation: "S1-attached",
675
- assurance: "multi-pass",
676
- },
677
- });
678
-
679
- export interface FanOutSuiteOptions extends FanOutInstructionOptions {
680
- readonly maxFindings?: number | undefined;
681
- }
682
-
683
- export const makeFanOutReviewSuite = (options: FanOutSuiteOptions = {}): FanOutReviewSuite => {
684
- const child = makeFileReviewerDefinition({ guidance: options.guidance });
685
- const delegation = makeFileReviewDelegation(child);
686
- return {
687
- child,
688
- parent: makeFanOutReviewerDefinition(options, delegation),
689
- delegation,
690
- };
691
- };
692
-
693
- const defaultSuite = makeFanOutReviewSuite();
694
-
695
- export const FileReviewer = defaultSuite.child;
696
- export const FanOutReviewer = defaultSuite.parent;
697
- export const fileReviewDelegation = defaultSuite.delegation;
698
- export const DelegateFileReview = delegationToolFor(fileReviewDelegation);
699
- export const FanOutReviewToolkit = FanOutReviewer.toolkit;
700
- export const FileReviewDelegationFailure = fileReviewDelegation.containedFailure;
701
-
702
- export const fanOutHandlersLayerFor =
703
- (delegation: ReturnType<typeof makeFileReviewDelegation>) =>
704
- <Provider, ModelProvides, ModelRequires>(
705
- childBinding: RuntimeBinding<
706
- typeof FileReviewBrief,
707
- typeof FileReviewReport,
708
- ReturnType<typeof makeFileReviewerInstructions>,
709
- Toolkit.Tools<typeof FileReviewToolkit>,
710
- Provider,
711
- ModelProvides,
712
- ModelRequires
713
- >,
714
- ) =>
715
- SubagentRuntime.layer(delegation, childBinding, {
716
- mapChildFailure: mapFileReviewChildFailure,
717
- });
718
-
719
- export const fanOutHandlersLayer = fanOutHandlersLayerFor(fileReviewDelegation);