@open-cr-agent/core 0.1.1 → 0.2.0

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,5 +1,8 @@
1
1
  import type { Usage } from "../contracts.js";
2
2
  export declare const REVIEW_BUDGET_SHARE = 0.8;
3
+ export declare class SpendLimitReached extends Error {
4
+ constructor(maxCostUsd: number);
5
+ }
3
6
  export interface SpendTracker {
4
7
  reviewExhausted(): boolean;
5
8
  exhausted(): boolean;
@@ -2,9 +2,17 @@
2
2
  // and Judge, so the run that hits the limit still gets its findings checked.
3
3
  // Those stages are one short call per file with findings plus one top-tier
4
4
  // call, while every agent step resends a task's whole conversation, so a fifth
5
- // covers them in typical runs. The reserve also absorbs review tasks that were
6
- // already running when their share ran out.
5
+ // covers them in typical runs. Review tasks still running when their share
6
+ // runs out are stopped; what they spend while stopping (the step in flight,
7
+ // and whatever the runtime had not reported yet) comes out of the reserve.
7
8
  export const REVIEW_BUDGET_SHARE = 0.8;
9
+ // Why running review tasks were stopped.
10
+ export class SpendLimitReached extends Error {
11
+ constructor(maxCostUsd) {
12
+ super(`stopped at the spend limit of $${maxCostUsd}`);
13
+ this.name = "SpendLimitReached";
14
+ }
15
+ }
8
16
  export function spendTracker(maxCostUsd, initial) {
9
17
  let spent = initial.reduce((sum, u) => sum + u.costUsd, 0);
10
18
  const over = (limit) => maxCostUsd !== undefined && spent >= limit;
@@ -19,6 +19,7 @@ export interface ExecuteOptions {
19
19
  relocate?: AnchorContext["relocate"] | undefined;
20
20
  plans?: Map<string, Promise<PlannedBundle>>;
21
21
  emit: (event: ReviewEvent) => void;
22
+ onUsage?: ((usage: Usage) => void) | undefined;
22
23
  signal: AbortSignal;
23
24
  }
24
25
  type PlannedBundle = Awaited<ReturnType<typeof planBundle>>;
@@ -43,6 +43,8 @@ export async function runJob(job, plan, options) {
43
43
  // The sample that made the call pays for it and reports its warning.
44
44
  if (!shared) {
45
45
  extraUsage.push(...planned.usage);
46
+ for (const usage of planned.usage)
47
+ options.onUsage?.(usage);
46
48
  if (planned.warning)
47
49
  extraWarnings.push(planned.warning);
48
50
  }
@@ -66,6 +68,7 @@ export async function runJob(job, plan, options) {
66
68
  timeoutMs: options.taskTimeoutMs,
67
69
  }, options.signal, {
68
70
  onProgress: (message) => emit({ type: "task_progress", taskId: job.taskId, message }),
71
+ onUsage: options.onUsage,
69
72
  category: job.reviewer.category,
70
73
  abortGraceMs: options.abortGraceMs,
71
74
  });
@@ -55,6 +55,7 @@ export interface ReportOutput {
55
55
  files: string[];
56
56
  }[];
57
57
  anchoring?: AnchoringSummary;
58
+ spendLimit?: ReviewReport["spendLimit"];
58
59
  usage: Usage;
59
60
  warnings: string[];
60
61
  }
@@ -29,6 +29,8 @@ export function toReportOutput(report) {
29
29
  output.judgement = report.judgement;
30
30
  if (report.anchoring)
31
31
  output.anchoring = report.anchoring;
32
+ if (report.spendLimit)
33
+ output.spendLimit = report.spendLimit;
32
34
  if (report.rereview) {
33
35
  const r = report.rereview;
34
36
  output.rereview = {
@@ -13,7 +13,10 @@ export type CoverageEntry = {
13
13
  status: "excluded";
14
14
  reason: ExclusionReason;
15
15
  };
16
- export declare function coverageGaps(coverage: readonly CoverageEntry[]): {
16
+ export declare function coverageGaps(run: {
17
+ coverage: readonly CoverageEntry[];
18
+ tasks: readonly Pick<TaskOutcome, "status">[];
19
+ }): {
17
20
  notReviewed: number;
18
21
  nothingReviewed: boolean;
19
22
  };
@@ -60,6 +63,10 @@ export interface ReviewReport {
60
63
  dismissed: PriorFinding[];
61
64
  };
62
65
  anchoring?: AnchoringSummary;
66
+ spendLimit?: {
67
+ usd: number;
68
+ reached?: "review" | "total";
69
+ };
63
70
  usage: Usage;
64
71
  warnings: string[];
65
72
  }
@@ -1,11 +1,14 @@
1
1
  // How much of the selection this run actually reviewed. Every surface (exit
2
2
  // code, terminal, pull request summary) reads it from here, so none of them
3
3
  // can call a run that reviewed nothing "approved".
4
- export function coverageGaps(coverage) {
5
- const notReviewed = coverage.filter((c) => c.status === "failed" || c.status === "unreviewed").length;
4
+ export function coverageGaps(run) {
5
+ const notReviewed = run.coverage.filter((c) => c.status === "failed" || c.status === "unreviewed").length;
6
6
  // Unchanged files were reviewed by an earlier run, and their findings and
7
7
  // verdict carry over: a re-review whose new files all failed still has them.
8
- const reviewed = coverage.some((c) => c.status === "reviewed" || c.status === "unchanged");
8
+ // A file stays failed while any of its reviewers failed, so a reviewer that
9
+ // finished its tasks has still reviewed something (#263).
10
+ const reviewed = run.coverage.some((c) => c.status === "reviewed" || c.status === "unchanged") ||
11
+ run.tasks.some((t) => t.status === "completed");
9
12
  return { notReviewed, nothingReviewed: notReviewed > 0 && !reviewed };
10
13
  }
11
14
  export function summarizeAnchoring(findings, relocationCalls) {
@@ -6,14 +6,14 @@ import { priorCodePresence } from "../rereview/presence.js";
6
6
  import { reconcile, stillOpen } from "../rereview/reconcile.js";
7
7
  import { correctnessReviewer } from "../review/reviewers/correctness.js";
8
8
  import { markUnchecked, verifyFindings } from "../verify/verify.js";
9
- import { spendTracker } from "./budget.js";
9
+ import { SpendLimitReached, spendTracker } from "./budget.js";
10
10
  import { runJob } from "./execute.js";
11
11
  import { dedupeFindings } from "./findings.js";
12
12
  import { DEFAULT_MAX_TASKS, planTasks } from "./matrix.js";
13
13
  import { planReview } from "./plan.js";
14
14
  import { mapWithConcurrency } from "./pool.js";
15
15
  import { coverageGaps, summarizeAnchoring, } from "./report.js";
16
- import { addUsage, emptyUsage } from "./usage.js";
16
+ import { addUsage, emptyUsage, unpricedCalls } from "./usage.js";
17
17
  export { GUIDELINES_PATH } from "./plan.js";
18
18
  const DEFAULTS = { concurrency: 4, taskTimeoutMs: 10 * 60_000, runTimeoutMs: 25 * 60_000 };
19
19
  // Plan (deterministic stages) → execute (one agent task per cell) → report.
@@ -52,6 +52,15 @@ export async function runReview(options) {
52
52
  relocationUsage.push(u);
53
53
  budget.add(u);
54
54
  }));
55
+ // Tasks report spend while they run, so the one that uses up the review
56
+ // share stops every task still running, not only the ones not yet started.
57
+ const spendLimit = new AbortController();
58
+ const spend = (usage) => {
59
+ budget.add(usage);
60
+ if (budget.reviewExhausted() && !spendLimit.signal.aborted) {
61
+ spendLimit.abort(new SpendLimitReached(options.maxCostUsd ?? 0));
62
+ }
63
+ };
55
64
  const execute = {
56
65
  runtime: options.runtime,
57
66
  taskTimeoutMs: options.taskTimeoutMs ?? DEFAULTS.taskTimeoutMs,
@@ -62,25 +71,34 @@ export async function runReview(options) {
62
71
  ultra: options.ultra === true,
63
72
  plans: new Map(),
64
73
  emit,
65
- signal,
74
+ onUsage: spend,
75
+ signal: AbortSignal.any([signal, spendLimit.signal]),
66
76
  };
77
+ // Tasks that never started leave their files unreviewed, not failed.
78
+ const notStarted = new Set();
79
+ let unaffordable = 0;
67
80
  const results = await mapWithConcurrency(matrix.cells, options.concurrency ?? DEFAULTS.concurrency, async (cell) => {
68
81
  // Once the run is cancelled or timed out, remaining cells are not started.
69
- if (signal.aborted)
82
+ if (signal.aborted) {
83
+ notStarted.add(cell.taskId);
70
84
  return skipCell(cell, "run cancelled before this task started", emit);
85
+ }
71
86
  if (budget.reviewExhausted()) {
87
+ notStarted.add(cell.taskId);
88
+ unaffordable += 1;
72
89
  return skipCell(cell, `spend limit of $${options.maxCostUsd} reached`, emit);
73
90
  }
74
- const result = await runJob(cell, plan, execute);
75
- budget.add(result.usage);
76
- return result;
91
+ return runJob(cell, plan, execute);
77
92
  });
93
+ if (unaffordable > 0) {
94
+ plan.warnings.push(`spend limit of $${options.maxCostUsd} reached: ${unaffordable} review task(s) did not start; their files are reported as not reviewed`);
95
+ }
78
96
  // Memory and the previous review filter first, so Verify and Judge are not
79
97
  // paid for findings that will not be reported, and a person's dismissal
80
98
  // keeps a finding out of the verdict whatever the models say.
81
99
  const concurrency = options.concurrency ?? DEFAULTS.concurrency;
82
100
  const found = dedupeFindings(results.flatMap((r) => r.findings));
83
- const fileCoverage = coverage(plan.decisions, results, plan.unchanged, matrix.limited ?? []);
101
+ const fileCoverage = coverage(plan.decisions, results, plan.unchanged, matrix.limited ?? [], notStarted);
84
102
  const remembered = applyMemory(found, plan.memory);
85
103
  const reported = new Set(found.map((f) => f.fingerprint));
86
104
  const priorReview = withoutRemembered(prior.review, plan.memory);
@@ -129,12 +147,22 @@ export async function runReview(options) {
129
147
  if (judgeWanted && !judgeAffordable) {
130
148
  judged.warnings.push(`spend limit of $${options.maxCostUsd} reached: findings were not judged`);
131
149
  }
132
- const { nothingReviewed } = coverageGaps(fileCoverage);
150
+ const { nothingReviewed } = coverageGaps({
151
+ coverage: fileCoverage,
152
+ tasks: results.map((r) => r.outcome),
153
+ });
133
154
  if (!nothingReviewed) {
134
155
  emit(judged.decisions
135
156
  ? { type: "judge_finished", verdict: judged.verdict, judgement: judged.decisions }
136
157
  : { type: "judge_finished", verdict: judged.verdict });
137
158
  }
159
+ const calls = [
160
+ ...plan.usage,
161
+ ...results.map((r) => r.usage),
162
+ ...relocationUsage,
163
+ ...verification.usage,
164
+ ...judged.usage,
165
+ ];
138
166
  const report = {
139
167
  changeRequest: plan.changeRequest,
140
168
  tier: plan.tier,
@@ -152,14 +180,9 @@ export async function runReview(options) {
152
180
  unverifiedCriticals: countMissedCriticals(verification.kept, verification.missed),
153
181
  refuted: verification.refuted,
154
182
  remembered: remembered.remembered,
155
- usage: sumUsage([
156
- ...plan.usage,
157
- ...results.map((r) => r.usage),
158
- ...relocationUsage,
159
- ...verification.usage,
160
- ...judged.usage,
161
- ]),
183
+ usage: sumUsage(calls),
162
184
  warnings: [
185
+ ...unpricedWarning(calls),
163
186
  ...plan.warnings,
164
187
  ...results.flatMap((r) => r.warnings),
165
188
  ...verification.warnings,
@@ -168,6 +191,14 @@ export async function runReview(options) {
168
191
  };
169
192
  if (judged.decisions)
170
193
  report.judgement = judged.decisions;
194
+ if (options.maxCostUsd !== undefined) {
195
+ const reached = budget.exhausted()
196
+ ? "total"
197
+ : spendLimit.signal.aborted || unaffordable > 0
198
+ ? "review"
199
+ : undefined;
200
+ report.spendLimit = { usd: options.maxCostUsd, ...(reached ? { reached } : {}) };
201
+ }
171
202
  report.anchoring = summarizeAnchoring(report.findings, relocationUsage.length);
172
203
  if (prior.review) {
173
204
  report.rereview = {
@@ -239,18 +270,20 @@ async function loadPriorReview(vcs) {
239
270
  return { warning: `could not load the previous review: ${errorMessage(error)}` };
240
271
  }
241
272
  }
242
- function coverage(decisions, results, unchanged, limited) {
273
+ function coverage(decisions, results, unchanged, limited, notStarted) {
243
274
  // A file is reviewed when every reviewer assigned to it finished at least
244
275
  // one of its tasks: under --ultra one completed sample is enough. A
245
- // reviewer whose task failed makes the file "failed"; one the task limit
246
- // never started makes it "unreviewed".
276
+ // reviewer whose task failed makes the file "failed"; one whose task never
277
+ // started (the task or spend limit, a cancelled run) makes it "unreviewed".
247
278
  const done = new Map();
248
279
  const ran = new Set();
249
280
  const key = (reviewer, file) => `${reviewer}\0${file}`;
250
281
  for (const { outcome } of results) {
282
+ const started = !notStarted.has(outcome.taskId);
251
283
  for (const file of outcome.files) {
252
284
  const k = key(outcome.reviewer, file);
253
- ran.add(k);
285
+ if (started)
286
+ ran.add(k);
254
287
  done.set(k, done.get(k) === true || outcome.status === "completed");
255
288
  }
256
289
  }
@@ -286,6 +319,14 @@ function sortFindings(findings) {
286
319
  a.file.localeCompare(b.file) ||
287
320
  (a.lineRange?.start ?? 0) - (b.lineRange?.start ?? 0));
288
321
  }
322
+ function unpricedWarning(calls) {
323
+ const unpriced = unpricedCalls(calls);
324
+ return unpriced > 0
325
+ ? [
326
+ `${unpriced} model call(s) used tokens but reported no cost: their model has no price, so the reported cost and the spend limit do not count them`,
327
+ ]
328
+ : [];
329
+ }
289
330
  function sumUsage(usages) {
290
331
  return usages.reduce(addUsage, emptyUsage());
291
332
  }
@@ -10,6 +10,7 @@ export interface TaskResult {
10
10
  }
11
11
  export interface TaskCallbacks {
12
12
  onProgress(message: string): void;
13
+ onUsage?: ((usage: Usage) => void) | undefined;
13
14
  category: string;
14
15
  abortGraceMs?: number | undefined;
15
16
  }
@@ -1,5 +1,6 @@
1
1
  import { reportedFindingSchema } from "../domain.js";
2
2
  import { errorMessage } from "../errors.js";
3
+ import { SpendLimitReached } from "./budget.js";
3
4
  import { addUsage, emptyUsage } from "./usage.js";
4
5
  export async function executeTask(runtime, spec, runSignal, callbacks) {
5
6
  const timeout = AbortSignal.timeout(spec.timeoutMs);
@@ -30,9 +31,20 @@ export async function executeTask(runtime, spec, runSignal, callbacks) {
30
31
  if (signal.aborted) {
31
32
  const timedOut = timeout.aborted && !runSignal.aborted;
32
33
  result.status = timedOut ? "timed_out" : "cancelled";
33
- result.error = timedOut ? `timed out after ${spec.timeoutMs}ms` : "run cancelled";
34
- if (iterator)
35
- await collectAfterAbort(iterator, pending, result, callbacks);
34
+ result.error = timedOut
35
+ ? `timed out after ${spec.timeoutMs}ms`
36
+ : runSignal.reason instanceof SpendLimitReached
37
+ ? runSignal.reason.message
38
+ : "run cancelled";
39
+ const ending = iterator
40
+ ? await collectAfterAbort(iterator, pending, result, callbacks)
41
+ : undefined;
42
+ // The task's last usage report crossed the limit, and it finished
43
+ // anyway: its review is complete.
44
+ if (ending === "done" && runSignal.reason instanceof SpendLimitReached) {
45
+ result.status = "completed";
46
+ delete result.error;
47
+ }
36
48
  void iterator?.return?.();
37
49
  }
38
50
  else {
@@ -54,18 +66,19 @@ async function collectAfterAbort(iterator, pending, result, callbacks) {
54
66
  for (;;) {
55
67
  const event = await untilAborted(next, grace);
56
68
  if (event.done)
57
- return;
69
+ return undefined;
58
70
  const { value } = event;
59
71
  if (value.type === "usage" || value.type === "finding")
60
72
  handle(value, result, callbacks);
61
73
  if (value.type === "done" || value.type === "error")
62
- return;
74
+ return value.type;
63
75
  next = iterator.next();
64
76
  }
65
77
  }
66
78
  catch {
67
79
  // The runtime did not finish within the grace period, or failed: keep
68
80
  // what arrived.
81
+ return undefined;
69
82
  }
70
83
  }
71
84
  function handle(event, result, callbacks) {
@@ -88,9 +101,12 @@ function handle(event, result, callbacks) {
88
101
  }
89
102
  return false;
90
103
  }
91
- case "usage":
92
- result.usage = addUsage(result.usage, event);
104
+ case "usage": {
105
+ const { type: _type, taskId: _taskId, ...usage } = event;
106
+ result.usage = addUsage(result.usage, usage);
107
+ callbacks.onUsage?.(usage);
93
108
  return false;
109
+ }
94
110
  case "done":
95
111
  return true;
96
112
  case "error":
@@ -1,4 +1,5 @@
1
1
  import type { Usage } from "../contracts.js";
2
2
  export declare function emptyUsage(): Usage;
3
3
  export declare function addUsage(total: Usage, next: Usage): Usage;
4
+ export declare function unpricedCalls(usages: readonly Usage[]): number;
4
5
  //# sourceMappingURL=usage.d.ts.map
@@ -10,4 +10,10 @@ export function addUsage(total, next) {
10
10
  costUsd: total.costUsd + next.costUsd,
11
11
  };
12
12
  }
13
+ // Calls that used tokens but cost nothing: their model has no price (a
14
+ // declared model priced at 0, or one missing from the pricing catalog), so
15
+ // reported cost and the spend limit cannot count them.
16
+ export function unpricedCalls(usages) {
17
+ return usages.filter((u) => u.inputTokens + u.outputTokens > 0 && u.costUsd === 0).length;
18
+ }
13
19
  //# sourceMappingURL=usage.js.map
@@ -11,6 +11,17 @@ export interface RuntimeOptions {
11
11
  models: ModelChains;
12
12
  tools: readonly ToolDefinition[];
13
13
  env: Env;
14
+ providers?: Readonly<Record<string, CustomProvider>>;
15
+ }
16
+ export interface CustomProvider {
17
+ baseUrl: string;
18
+ apiKeyEnv?: string;
19
+ models: Readonly<Record<string, ModelPrice>>;
20
+ }
21
+ export interface ModelPrice {
22
+ input: number;
23
+ output: number;
24
+ cachedInput?: number;
14
25
  }
15
26
  export type VcsFactory = (options: unknown) => VcsAdapter;
16
27
  export type RuntimeFactory = (options: RuntimeOptions) => AgentRuntime;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/core",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Domain types, review pipeline stages and plugin contracts for Open-CR-Agent",
5
5
  "keywords": [
6
6
  "code-review",