@effect-agent/pr-review 0.1.0-beta.22 → 0.1.0-beta.24

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,23 +1,17 @@
1
- import { Effect, Layer } from "effect";
1
+ import { Effect, Layer, Schema } from "effect";
2
2
  import {
3
3
  Agent,
4
4
  AgentPolicy,
5
5
  getToolExecutionClass,
6
6
  IdGenerator,
7
- SubagentReservationsMemoryLive,
8
7
  type AgentPolicyInput,
9
8
  type UsageBudgetLimits,
10
9
  } from "effect-agent";
11
10
  import { Toolkit, type LanguageModel, type Model, type Tool } from "effect/unstable/ai";
12
11
 
13
12
  import type { ChangedFile } from "./diff.ts";
14
- import {
15
- fanOutHandlersLayerFor,
16
- FanOutCoordinatorToolkitLayer,
17
- FileReviewToolkitLayer,
18
- makeFanOutReviewSuite,
19
- } from "./fan-out.ts";
20
- import { computeChangesetFingerprint } from "./fingerprint.ts";
13
+ import { makeFileReviewerDefinition } from "./fan-out.ts";
14
+ import { computeChangesetFingerprint, computeProfileFingerprint } from "./fingerprint.ts";
21
15
  import { compileIgnoreGlobs, ignoringPullRequestSourceLayer } from "./ignore.ts";
22
16
  import {
23
17
  clampMaxFindings,
@@ -32,9 +26,10 @@ import {
32
26
  resolveGuidance as resolveReviewGuidance,
33
27
  type ReviewGuidance,
34
28
  } from "./review-agent.ts";
35
- import { buildProfileMission, computeProfileFingerprint } from "./review-state.ts";
29
+ import { buildProfileMission } from "./review-state.ts";
36
30
  import {
37
31
  buildReviewMission,
32
+ executeFanOutReview,
38
33
  executeReview,
39
34
  fanOutReviewBudgetLimits,
40
35
  reviewBudgetLimits,
@@ -180,8 +175,8 @@ const makeReviewSnapshot = (ignore: ReadonlyArray<string> | undefined) =>
180
175
  * Build the flat reviewer: one bounded read-only agent over the whole
181
176
  * changeset. Returns the model-agnostic definition, the explicit binding, and
182
177
  * a `run` whose error and requirement channels stay fully inferred — the
183
- * pull-request source, the publisher, extra tool handlers, and the Model
184
- * Layer's requirements all remain visible to the caller.
178
+ * pull-request source, the publisher, `Crypto.Crypto`, extra tool handlers,
179
+ * and the Model Layer's requirements all remain visible to the caller.
185
180
  */
186
181
  const make = <
187
182
  Provider,
@@ -244,8 +239,6 @@ const make = <
244
239
  signature,
245
240
  modelLabel: options.modelLabel,
246
241
  runUrl: runOptions.runUrl,
247
- usageScope: "run",
248
- reviewShape: "flat",
249
242
  }).pipe(Effect.provide(Layer.mergeAll(ReviewToolkitLayer, IdGenerator.layer)), Effect.scoped),
250
243
  options.ignore,
251
244
  );
@@ -264,44 +257,39 @@ const make = <
264
257
  } as const;
265
258
  };
266
259
 
267
- /** Options accepted by `PrReview.makeFanOut` (the delegating reviewer). */
260
+ /** Options accepted by `PrReview.makeFanOut` (the host-scheduled pipeline). */
268
261
  export interface PrReviewFanOutOptions<
269
262
  Provider,
270
263
  ModelProvides,
271
264
  ModelRequires,
272
265
  > extends PrReviewSharedOptions {
273
- /** The Effect AI Model bound to both the coordinator and its children. */
266
+ /** The Effect AI Model bound to every child review pass. */
274
267
  readonly model: Model.Model<Provider, LanguageModel.LanguageModel | ModelProvides, ModelRequires>;
275
268
  /**
276
269
  * Static guidance injected into every child reviewer's instructions. The
277
- * coordinator's mission never crosses the delegation boundary, so
270
+ * pipeline schedules children from the deterministic plan, so
278
271
  * mission-dependent guidance cannot exist for children.
279
272
  */
280
273
  readonly guidance?: string | ReadonlyArray<string> | undefined;
281
274
  }
282
275
 
283
276
  /**
284
- * Build the fan-out reviewer: a coordinator schedules host-planned general
285
- * and specialist discovery plus independent candidate verification through
286
- * attached ephemeral children. Host code reconstructs publication only from
287
- * exactly confirmed candidates. Child and coordinator execution bounds are
288
- * packaged and not configurable here the delegation reservation mirrors
289
- * the child policy, and letting the two drift apart is a published-API hazard.
277
+ * Build the fan-out reviewer: host code schedules host-planned general and
278
+ * specialist discovery plus independent candidate verification directly as
279
+ * bounded child runs there is no coordinator model and no delegation tool,
280
+ * so review assurance cannot fail on scheduling compliance. Host code
281
+ * composes publication only from exactly confirmed candidates. Child
282
+ * execution bounds are packaged and not configurable here.
290
283
  */
291
284
  const makeFanOut = <Provider, ModelProvides, ModelRequires>(
292
285
  options: PrReviewFanOutOptions<Provider, ModelProvides, ModelRequires>,
293
286
  ) => {
294
- const suite = makeFanOutReviewSuite({
295
- guidance: options.guidance,
296
- maxFindings: options.maxFindings,
297
- });
298
- // Structural bindings for the same reason as in `make` above.
299
- const binding = Object.freeze({ definition: suite.parent, model: options.model });
300
- const childBinding = Object.freeze({ definition: suite.child, model: options.model });
287
+ const child = makeFileReviewerDefinition({ guidance: options.guidance });
288
+ // Structural binding for the same reason as in `make` above.
289
+ const childBinding = Object.freeze({ definition: child, model: options.model });
301
290
 
302
- // The coordinator's rendered instructions (mission, guidance, findings
303
- // bound, contract) plus the review-shaping options they do not carry: the
304
- // child guidance, the host knobs, and the model binding descriptor.
291
+ // Everything that shapes this pipeline's output: the child instructions
292
+ // (guidance included) plus the host knobs the children do not carry.
305
293
  const guidanceLines =
306
294
  options.guidance === undefined
307
295
  ? []
@@ -310,31 +298,30 @@ const makeFanOut = <Provider, ModelProvides, ModelRequires>(
310
298
  : options.guidance;
311
299
  const signature = (mission: ReviewMission): string =>
312
300
  [
313
- suite.parent.instructions(mission),
301
+ "pr-review-fan-out-host-scheduled-v1",
302
+ // Children never see the mission, but the documented skip-unchanged
303
+ // contract includes pull-request framing, so the fingerprint keeps it.
304
+ JSON.stringify(Schema.encodeSync(ReviewMission)(mission)),
314
305
  `childGuidance=${JSON.stringify(guidanceLines)}`,
306
+ `maxFindings=${clampMaxFindings(options.maxFindings)}`,
315
307
  `applyVerdict=${String(options.applyVerdict ?? false)}`,
316
308
  ...(options.modelLabel === undefined ? [] : [`model=${options.modelLabel}`]),
317
309
  ].join(" ");
318
310
  const profileSignature = (_mission: ReviewMission): string =>
319
311
  [
320
- // v3 invalidates continuity produced before complete evidence sharding,
321
- // universal specialist scrutiny, and request-bound result projection.
322
- "pr-review-profile-v3-sharded-request-bound-assurance",
312
+ // v4 invalidates continuity produced by the coordinator-scheduled
313
+ // pipeline; the host now schedules every pass and retries failures.
314
+ "pr-review-profile-v4-host-scheduled",
323
315
  JSON.stringify(guidanceLines),
324
316
  JSON.stringify(options.ignore ?? []),
325
317
  `maxFindings=${clampMaxFindings(options.maxFindings)}`,
326
318
  `applyVerdict=${String(options.applyVerdict ?? false)}`,
327
319
  ...(options.modelLabel === undefined ? [] : [`model=${options.modelLabel}`]),
328
- ].join("\u0000");
329
- const delegationLayer = fanOutHandlersLayerFor(suite.delegation)(childBinding).pipe(
330
- Layer.provide(
331
- Layer.mergeAll(FileReviewToolkitLayer, SubagentReservationsMemoryLive, IdGenerator.layer),
332
- ),
333
- );
320
+ ].join(" ");
334
321
 
335
322
  const run = (runOptions: RunReviewOptions = {}) =>
336
323
  provideIgnore(
337
- executeReview(binding, {
324
+ executeFanOutReview(childBinding, {
338
325
  post: runOptions.post ?? false,
339
326
  applyVerdict: options.applyVerdict ?? false,
340
327
  limits: options.budget ?? fanOutReviewBudgetLimits,
@@ -342,20 +329,12 @@ const makeFanOut = <Provider, ModelProvides, ModelRequires>(
342
329
  signature,
343
330
  modelLabel: options.modelLabel,
344
331
  runUrl: runOptions.runUrl,
345
- usageScope: "coordinator",
346
- reviewShape: "fan-out",
347
- }).pipe(
348
- Effect.provide(
349
- Layer.mergeAll(FanOutCoordinatorToolkitLayer, delegationLayer, IdGenerator.layer),
350
- ),
351
- Effect.scoped,
352
- ),
332
+ }).pipe(Effect.provide(IdGenerator.layer), Effect.scoped),
353
333
  options.ignore,
354
334
  );
355
335
 
356
336
  return {
357
- definition: suite.parent,
358
- binding,
337
+ definition: child,
359
338
  childBinding,
360
339
  run,
361
340
  fingerprint: makeFingerprint(signature, options.ignore),
@@ -1,83 +1,14 @@
1
1
  import { Effect, Layer, Ref, Schema, Stream } from "effect";
2
- import { LanguageModel, Model, type Response } from "effect/unstable/ai";
2
+ import { LanguageModel, Model } from "effect/unstable/ai";
3
3
 
4
- import { FileReviewReport, FileReviewRequest } from "./fan-out.ts";
5
- import { CodeReview } from "./review-agent.ts";
6
- import { makePromptKeyedModel, scriptedFinalParts, scriptedToolTurn } from "./scripted.ts";
4
+ import { FileReviewReport } from "./fan-out.ts";
5
+ import { scriptedFinalParts } from "./scripted.ts";
7
6
 
8
- // Deterministic offline models for the complete fan-out protocol. Decisions
9
- // key on committed Tool Call IDs or work IDs in each prompt, never call order,
10
- // so bounded child concurrency cannot make the fixtures flaky.
11
-
12
- export const OFFLINE_UNITS_CALL_ID = "units-1";
13
-
14
- export const offlineUnitCallId = (workId: string): string => `delegate-${workId}`;
15
-
16
- export type OfflineUnitCall = FileReviewRequest;
17
-
18
- const scriptedUnitCallId = (calls: ReadonlyArray<OfflineUnitCall>, index: number): string => {
19
- const call = calls[index];
20
- if (call === undefined) return "delegate-none";
21
- const occurrence = calls
22
- .slice(0, index + 1)
23
- .filter((candidate) => candidate.workId === call.workId).length;
24
- const base = offlineUnitCallId(call.workId);
25
- return occurrence === 1 ? base : `${base}-${occurrence}`;
26
- };
27
-
28
- export const makeOfflineFanOutCoordinatorModel = (script: {
29
- readonly discoveryCalls: ReadonlyArray<OfflineUnitCall>;
30
- readonly verificationCalls: ReadonlyArray<OfflineUnitCall>;
31
- readonly review: CodeReview;
32
- }) => {
33
- const firstDiscovery = scriptedUnitCallId(script.discoveryCalls, 0);
34
- const firstVerification = scriptedUnitCallId(script.verificationCalls, 0);
35
- return makePromptKeyedModel("pr-fanout-coordinator-offline", (promptJson) => {
36
- if (script.discoveryCalls.length === 0 && promptJson.includes(OFFLINE_UNITS_CALL_ID)) {
37
- return scriptedFinalParts(JSON.stringify(Schema.encodeSync(CodeReview)(script.review)));
38
- }
39
- if (
40
- script.verificationCalls.length === 0
41
- ? promptJson.includes(firstDiscovery)
42
- : promptJson.includes(firstVerification)
43
- ) {
44
- return scriptedFinalParts(JSON.stringify(Schema.encodeSync(CodeReview)(script.review)));
45
- }
46
- if (promptJson.includes(firstDiscovery)) {
47
- return scriptedToolTurn(
48
- ...script.verificationCalls.map(
49
- (call, index): Response.StreamPartEncoded => ({
50
- type: "tool-call",
51
- id: scriptedUnitCallId(script.verificationCalls, index),
52
- name: "delegate_file_review",
53
- params: Schema.encodeSync(FileReviewRequest)(call),
54
- providerExecuted: false,
55
- }),
56
- ),
57
- );
58
- }
59
- if (promptJson.includes(OFFLINE_UNITS_CALL_ID)) {
60
- return scriptedToolTurn(
61
- ...script.discoveryCalls.map(
62
- (call, index): Response.StreamPartEncoded => ({
63
- type: "tool-call",
64
- id: scriptedUnitCallId(script.discoveryCalls, index),
65
- name: "delegate_file_review",
66
- params: Schema.encodeSync(FileReviewRequest)(call),
67
- providerExecuted: false,
68
- }),
69
- ),
70
- );
71
- }
72
- return scriptedToolTurn({
73
- type: "tool-call",
74
- id: OFFLINE_UNITS_CALL_ID,
75
- name: "list_review_units",
76
- params: { scope: "all" },
77
- providerExecuted: false,
78
- });
79
- });
80
- };
7
+ // Deterministic offline child models for the host-scheduled fan-out pipeline.
8
+ // Decisions key on the work ID committed into each child's instructions,
9
+ // never call order, so bounded pass concurrency cannot make fixtures flaky.
10
+ // Each work ID carries a SEQUENCE of outcomes consumed per call, so tests can
11
+ // script "fail once, then settle" and pin the pipeline's bounded retry.
81
12
 
82
13
  export type OfflineUnitOutcome =
83
14
  | { readonly _tag: "report"; readonly report: FileReviewReport }
@@ -85,13 +16,15 @@ export type OfflineUnitOutcome =
85
16
 
86
17
  export interface OfflineUnitScript {
87
18
  readonly workId: string;
88
- readonly outcome: OfflineUnitOutcome;
19
+ /** Consumed one per child call for this work ID; the last outcome repeats. */
20
+ readonly outcomes: ReadonlyArray<OfflineUnitOutcome>;
89
21
  }
90
22
 
91
23
  export const makeOfflineFileReviewerModel = (scripts: ReadonlyArray<OfflineUnitScript>) =>
92
24
  Effect.gen(function* () {
93
25
  const calls = yield* Ref.make(0);
94
26
  const prompts = yield* Ref.make<ReadonlyArray<string>>([]);
27
+ const callsByWorkId = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
95
28
  const model = Model.make(
96
29
  "scripted",
97
30
  "pr-fanout-file-reviewer-offline",
@@ -115,10 +48,23 @@ export const makeOfflineFileReviewerModel = (scripts: ReadonlyArray<OfflineUnitS
115
48
  }
116
49
  const [selected] = script;
117
50
  if (selected === undefined) return yield* Effect.die("unreachable scripted match");
51
+ const occurrence = yield* Ref.modify(callsByWorkId, (byWorkId) => {
52
+ const count = byWorkId.get(selected.workId) ?? 0;
53
+ const next = new Map(byWorkId);
54
+ next.set(selected.workId, count + 1);
55
+ return [count, next] as const;
56
+ });
57
+ const outcome =
58
+ selected.outcomes[Math.min(occurrence, selected.outcomes.length - 1)];
59
+ if (outcome === undefined) {
60
+ return yield* Effect.die(
61
+ new Error(`Work ID ${selected.workId} scripts no outcomes`),
62
+ );
63
+ }
118
64
  return Stream.fromIterable(
119
65
  scriptedFinalParts(
120
- selected.outcome._tag === "report"
121
- ? JSON.stringify(Schema.encodeSync(FileReviewReport)(selected.outcome.report))
66
+ outcome._tag === "report"
67
+ ? JSON.stringify(Schema.encodeSync(FileReviewReport)(outcome.report))
122
68
  : "this is not valid review JSON",
123
69
  ),
124
70
  );