argus-reviewer-e2e 0.1.2 → 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/README.md +30 -5
- package/action/action.yml +22 -0
- package/action/sticky-comment.mjs +287 -79
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/cli.d.ts +67 -0
- package/dist/cli.js +463 -86
- package/dist/config.d.ts +105 -2
- package/dist/config.js +136 -9
- package/dist/debug.d.ts +1 -0
- package/dist/debug.js +9 -3
- package/dist/detect.d.ts +11 -1
- package/dist/detect.js +19 -3
- package/dist/evidence/ci.d.ts +47 -2
- package/dist/evidence/ci.js +98 -6
- package/dist/evidence/gate.d.ts +10 -0
- package/dist/evidence/gate.js +29 -0
- package/dist/evidence/link.d.ts +6 -1
- package/dist/evidence/link.js +7 -5
- package/dist/executor/sandbox.d.ts +105 -0
- package/dist/executor/sandbox.js +231 -0
- package/dist/probe/author.d.ts +50 -0
- package/dist/probe/author.js +149 -0
- package/dist/probe/harness.d.ts +30 -0
- package/dist/probe/harness.js +99 -0
- package/dist/probe/queue.d.ts +105 -0
- package/dist/probe/queue.js +446 -0
- package/dist/review/adjudicate.d.ts +63 -0
- package/dist/review/adjudicate.js +111 -0
- package/dist/review/secrets.d.ts +88 -0
- package/dist/review/secrets.js +220 -0
- package/dist/review/triage.d.ts +76 -0
- package/dist/review/triage.js +163 -0
- package/dist/trust.d.ts +50 -0
- package/dist/trust.js +103 -0
- package/dist/vision/cost.d.ts +14 -1
- package/dist/vision/cost.js +13 -0
- package/dist/vision/decisions.d.ts +95 -0
- package/dist/vision/decisions.js +232 -0
- package/package.json +3 -2
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { debug } from '../debug.js';
|
|
2
|
+
import { makeDecisionsCallCost } from './cost.js';
|
|
3
|
+
/**
|
|
4
|
+
* Pinned Jev slug — the alias `~typesafe/jev-latest` drifts silently and
|
|
5
|
+
* adjudication thresholds are calibrated to a version. The alias stays
|
|
6
|
+
* usable via `config.decisionModel` for experimentation.
|
|
7
|
+
*/
|
|
8
|
+
export const JEV_DEFAULT_MODEL = 'typesafe/jev-1.13-20260917';
|
|
9
|
+
const DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
|
|
10
|
+
const TIMEOUT_MS = 15_000;
|
|
11
|
+
const MAX_CHOICE_OPTIONS = 255;
|
|
12
|
+
const MIN_SCORE_LEVELS = 2;
|
|
13
|
+
const MAX_SCORE_LEVELS = 10;
|
|
14
|
+
export class DecisionError extends Error {
|
|
15
|
+
kind;
|
|
16
|
+
retryable;
|
|
17
|
+
constructor(kind, message, retryable) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.kind = kind;
|
|
20
|
+
this.retryable = retryable;
|
|
21
|
+
this.name = 'DecisionError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Type guards over the answer union — one `in` check per lane otherwise. */
|
|
25
|
+
export const isNoulAnswer = (a) => 'noul' in a;
|
|
26
|
+
export const isChoiceAnswer = (a) => 'choice' in a;
|
|
27
|
+
export const isScoreAnswer = (a) => 'score' in a;
|
|
28
|
+
/** Short error label for lane debug lines: DecisionError kind, else message. */
|
|
29
|
+
export function describeDecisionError(e) {
|
|
30
|
+
return e instanceof DecisionError ? e.kind : e.message;
|
|
31
|
+
}
|
|
32
|
+
/** Shared per-call batch cap for the Jev lanes (secrets, findings, triage). */
|
|
33
|
+
export const MAX_CANDIDATES = 50;
|
|
34
|
+
function isRecord(v) {
|
|
35
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
36
|
+
}
|
|
37
|
+
function validateQuestions(questions) {
|
|
38
|
+
const entries = Object.entries(questions);
|
|
39
|
+
if (entries.length === 0) {
|
|
40
|
+
throw new DecisionError('validation', 'decide: at least one question is required', false);
|
|
41
|
+
}
|
|
42
|
+
for (const [id, q] of entries) {
|
|
43
|
+
if (id === '') {
|
|
44
|
+
throw new DecisionError('validation', 'decide: question IDs must be non-empty', false);
|
|
45
|
+
}
|
|
46
|
+
if (q.type === 'choice') {
|
|
47
|
+
const n = Object.keys(q.criteria).length;
|
|
48
|
+
if (n === 0 || n > MAX_CHOICE_OPTIONS) {
|
|
49
|
+
throw new DecisionError('validation', `decide: choice question "${id}" needs 1-${MAX_CHOICE_OPTIONS} options, got ${n}`, false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (q.type === 'score') {
|
|
53
|
+
const n = q.criteria.length;
|
|
54
|
+
if (n < MIN_SCORE_LEVELS || n > MAX_SCORE_LEVELS) {
|
|
55
|
+
throw new DecisionError('validation', `decide: score question "${id}" needs ${MIN_SCORE_LEVELS}-${MAX_SCORE_LEVELS} levels, got ${n}`, false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (q.type !== 'noul') {
|
|
59
|
+
throw new DecisionError('validation', `decide: question "${id}" has unknown type`, false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function validateAnswers(questions, answers) {
|
|
64
|
+
// Per-question salvage: a missing or malformed answer is skipped, not
|
|
65
|
+
// fatal — every caller degrades open per item (undefined answer →
|
|
66
|
+
// unadjudicated), so one bad f_i must not void the whole batch.
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [id, q] of Object.entries(questions)) {
|
|
69
|
+
const a = answers[id];
|
|
70
|
+
if (!isRecord(a))
|
|
71
|
+
continue;
|
|
72
|
+
if (q.type === 'noul') {
|
|
73
|
+
if (typeof a.noul === 'number' && Number.isFinite(a.noul) && a.noul >= 0 && a.noul <= 1) {
|
|
74
|
+
out[id] = { noul: a.noul };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (q.type === 'choice') {
|
|
78
|
+
const confOk = a.confidence === undefined ||
|
|
79
|
+
(typeof a.confidence === 'number' &&
|
|
80
|
+
Number.isFinite(a.confidence) &&
|
|
81
|
+
a.confidence >= 0 &&
|
|
82
|
+
a.confidence <= 1);
|
|
83
|
+
if (typeof a.choice === 'string' && Object.hasOwn(q.criteria, a.choice) && confOk) {
|
|
84
|
+
out[id] = a;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// Score answers must land inside the rubric — an out-of-range
|
|
89
|
+
// score could otherwise satisfy a low-risk routing predicate.
|
|
90
|
+
const maxScore = Array.isArray(q.criteria) ? q.criteria.length : 0;
|
|
91
|
+
if (typeof a.score === 'number' &&
|
|
92
|
+
Number.isFinite(a.score) &&
|
|
93
|
+
a.score >= 1 &&
|
|
94
|
+
a.score <= maxScore) {
|
|
95
|
+
out[id] = a;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
function classifyStatus(status) {
|
|
102
|
+
if (status === 401 || status === 403) {
|
|
103
|
+
return new DecisionError('auth', `decide: HTTP ${status} — check OPENROUTER_API_KEY`, false);
|
|
104
|
+
}
|
|
105
|
+
if (status === 400 || status === 404 || status === 422) {
|
|
106
|
+
return new DecisionError('validation', `decide: HTTP ${status} — request rejected`, false);
|
|
107
|
+
}
|
|
108
|
+
if (status === 429) {
|
|
109
|
+
return new DecisionError('rate_limited', 'decide: HTTP 429 rate limited', true);
|
|
110
|
+
}
|
|
111
|
+
if (status === 503) {
|
|
112
|
+
return new DecisionError('overloaded', 'decide: HTTP 503 provider overloaded', true);
|
|
113
|
+
}
|
|
114
|
+
if (status >= 500) {
|
|
115
|
+
return new DecisionError('server_error', `decide: HTTP ${status}`, true);
|
|
116
|
+
}
|
|
117
|
+
return new DecisionError('unexpected', `decide: HTTP ${status}`, false);
|
|
118
|
+
}
|
|
119
|
+
function retryDelayMs(res) {
|
|
120
|
+
const retryAfterMs = res.headers.get('retry-after-ms');
|
|
121
|
+
if (retryAfterMs !== null) {
|
|
122
|
+
const parsed = Number(retryAfterMs);
|
|
123
|
+
if (Number.isFinite(parsed) && parsed >= 0)
|
|
124
|
+
return Math.min(parsed, 30_000);
|
|
125
|
+
}
|
|
126
|
+
const retryAfter = res.headers.get('retry-after');
|
|
127
|
+
if (retryAfter !== null) {
|
|
128
|
+
const parsed = Number(retryAfter);
|
|
129
|
+
if (Number.isFinite(parsed) && parsed >= 0)
|
|
130
|
+
return Math.min(parsed * 1000, 30_000);
|
|
131
|
+
}
|
|
132
|
+
return 1_000;
|
|
133
|
+
}
|
|
134
|
+
export class DecisionClient {
|
|
135
|
+
_apiKey;
|
|
136
|
+
_fetch;
|
|
137
|
+
_trace;
|
|
138
|
+
_timeoutMs;
|
|
139
|
+
_onCall;
|
|
140
|
+
constructor(opts) {
|
|
141
|
+
if (!opts.apiKey) {
|
|
142
|
+
throw new Error('OPENROUTER_API_KEY is required: pass a non-empty apiKey at construction');
|
|
143
|
+
}
|
|
144
|
+
this._apiKey = opts.apiKey;
|
|
145
|
+
this._fetch = opts.fetch ?? globalThis.fetch;
|
|
146
|
+
this._trace = opts.trace;
|
|
147
|
+
this._timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
|
|
148
|
+
this._onCall = opts.onCall;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Ask typed questions about `state`. Throws a typed `DecisionError` on
|
|
152
|
+
* any failure — callers catch and degrade to unadjudicated; a decision
|
|
153
|
+
* failure must never suppress findings (KTD6).
|
|
154
|
+
*/
|
|
155
|
+
async decide(opts) {
|
|
156
|
+
const model = opts.model ?? JEV_DEFAULT_MODEL;
|
|
157
|
+
validateQuestions(opts.questions);
|
|
158
|
+
const body = {
|
|
159
|
+
model,
|
|
160
|
+
state: opts.state,
|
|
161
|
+
questions: opts.questions,
|
|
162
|
+
};
|
|
163
|
+
if (this._trace)
|
|
164
|
+
body.trace = this._trace;
|
|
165
|
+
let lastErr;
|
|
166
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const timeout = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
169
|
+
try {
|
|
170
|
+
const res = await this._fetch(DECISIONS_URL, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
headers: {
|
|
174
|
+
Authorization: `Bearer ${this._apiKey}`,
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
'X-Title': 'argus-reviewer',
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify(body),
|
|
179
|
+
});
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
const err = classifyStatus(res.status);
|
|
182
|
+
if (err.retryable && attempt === 0) {
|
|
183
|
+
const delay = retryDelayMs(res);
|
|
184
|
+
debug('decisions', `${err.kind} — retrying in ${delay}ms`);
|
|
185
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
const data = (await res.json());
|
|
191
|
+
if (!isRecord(data.answers)) {
|
|
192
|
+
throw new DecisionError('unexpected', 'decide: response has no answers object', false);
|
|
193
|
+
}
|
|
194
|
+
const answers = validateAnswers(opts.questions, data.answers);
|
|
195
|
+
const cost = makeDecisionsCallCost(data, 'decide');
|
|
196
|
+
debug('decisions', `model=${cost.model} provider=${cost.provider} tokens=${cost.tokens} costUsd=${cost.costUsd.toFixed(6)}`);
|
|
197
|
+
this._onCall?.({
|
|
198
|
+
id: data.id ?? '',
|
|
199
|
+
model: cost.model,
|
|
200
|
+
provider: cost.provider,
|
|
201
|
+
kind: 'decide',
|
|
202
|
+
costUsd: cost.costUsd,
|
|
203
|
+
tokens: cost.tokens,
|
|
204
|
+
...(this._trace ? { trace: this._trace } : {}),
|
|
205
|
+
});
|
|
206
|
+
return { answers, cost, model: cost.model };
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
if (e instanceof DecisionError) {
|
|
210
|
+
lastErr = e;
|
|
211
|
+
// Validation/unexpected errors never retry — they retry identically.
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
const err = e;
|
|
215
|
+
if (err.name === 'AbortError') {
|
|
216
|
+
lastErr = new DecisionError('timeout', `decide: timed out after ${this._timeoutMs}ms`, true);
|
|
217
|
+
if (attempt === 0) {
|
|
218
|
+
debug('decisions', 'timeout — retrying once');
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
lastErr = new DecisionError('unexpected', `decide: ${err.message}`, false);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
clearTimeout(timeout);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
throw lastErr ?? new DecisionError('unexpected', 'decide: failed', false);
|
|
231
|
+
}
|
|
232
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "argus-reviewer-e2e",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "argus-reviewer — open-source vision-model E2E testing harness. Record, replay, heal, and assert with any OpenRouter model under a hard budget cap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"prepare": "npm run build && chmod +x dist/cli.js",
|
|
43
43
|
"eval": "node evals/run.mjs",
|
|
44
44
|
"watch": "node scripts/watch.mjs",
|
|
45
|
-
"app": "electron electron"
|
|
45
|
+
"app": "electron electron",
|
|
46
|
+
"demo": "node scripts/demo.mjs"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@eslint/js": "^9.0.0",
|