@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.
- package/dist/pipeline/budget.d.ts +3 -0
- package/dist/pipeline/budget.js +10 -2
- package/dist/pipeline/execute.d.ts +1 -0
- package/dist/pipeline/execute.js +3 -0
- package/dist/pipeline/output.d.ts +1 -0
- package/dist/pipeline/output.js +2 -0
- package/dist/pipeline/report.d.ts +8 -1
- package/dist/pipeline/report.js +6 -3
- package/dist/pipeline/run.js +61 -20
- package/dist/pipeline/task.d.ts +1 -0
- package/dist/pipeline/task.js +23 -7
- package/dist/pipeline/usage.d.ts +1 -0
- package/dist/pipeline/usage.js +6 -0
- package/dist/plugin/types.d.ts +11 -0
- package/package.json +1 -1
|
@@ -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;
|
package/dist/pipeline/budget.js
CHANGED
|
@@ -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.
|
|
6
|
-
//
|
|
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>>;
|
package/dist/pipeline/execute.js
CHANGED
|
@@ -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
|
});
|
package/dist/pipeline/output.js
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/dist/pipeline/report.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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) {
|
package/dist/pipeline/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
246
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/dist/pipeline/task.d.ts
CHANGED
package/dist/pipeline/task.js
CHANGED
|
@@ -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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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":
|
package/dist/pipeline/usage.d.ts
CHANGED
|
@@ -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
|
package/dist/pipeline/usage.js
CHANGED
|
@@ -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
|
package/dist/plugin/types.d.ts
CHANGED
|
@@ -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;
|