scenescout 3.14.1 → 3.16.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/CHANGELOG.md +94 -0
- package/README.md +90 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +211 -24
- package/dist/ci-run.js +374 -62
- package/dist/cli.js +221 -47
- package/dist/commands.js +42 -4
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1660 -386
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +258 -68
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +233 -18
- package/dist/engine/claims.js +234 -12
- package/dist/engine/collector.js +851 -78
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +355 -43
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +198 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +668 -70
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +167 -2
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/provider.js +4 -3
- package/dist/engine/refresh.js +338 -25
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +248 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/scripted-login.js +342 -62
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +581 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +386 -71
- package/dist/mcp-server.js +637 -68
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +51 -18
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a crawl makes of one route it loaded: whether an explicitly requested
|
|
3
|
+
* path joins the route contract, and the summary line it prints. Kept out of
|
|
4
|
+
* browser.ts so both rules can be table-tested without a browser.
|
|
5
|
+
*/
|
|
6
|
+
import { isNonPageRoute } from "./fingerprint.js";
|
|
7
|
+
/** The crawl flag for a main area showing only its state, or null. */
|
|
8
|
+
export function mainStateFlag(state) {
|
|
9
|
+
return state === "error" ? "ERROR-VIEW" : state === "loading" ? "STILL-LOADING" : null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The route an explicitly requested crawl path adds to the route contract,
|
|
13
|
+
* or null. A path someone asked for, that answered with a page and stayed
|
|
14
|
+
* where it was asked, is a route of the app whether or not any link names
|
|
15
|
+
* it: left out, a sweep of forty extra paths left "Routes visited: 37/37"
|
|
16
|
+
* unchanged and the report understated what was checked. A path that
|
|
17
|
+
* redirected is not added (the page it landed on is the route that exists),
|
|
18
|
+
* nor one that failed, nor a dead end (an app that answers 200 for every
|
|
19
|
+
* path), nor one whose main area showed only an error view or a loading
|
|
20
|
+
* placeholder (a client-rendered app answers 200 and draws its not-found
|
|
21
|
+
* view), nor an API or download path. Added routes are remembered across
|
|
22
|
+
* runs, so a probe of made-up paths must not become part of the contract.
|
|
23
|
+
*/
|
|
24
|
+
export function crawledRoute(o) {
|
|
25
|
+
if (typeof o.status !== "number" || o.status >= 400 || o.deadEnd || o.mainState)
|
|
26
|
+
return null;
|
|
27
|
+
if (o.loginRedirect || o.landedRoute !== o.requestedRoute)
|
|
28
|
+
return null;
|
|
29
|
+
if (isNonPageRoute(o.requestedRoute))
|
|
30
|
+
return null;
|
|
31
|
+
return o.requestedRoute;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The crawl's one line for a route. A path that ended on another route says
|
|
35
|
+
* so, as scout_navigate does: "/old — 200 · 94 el" read as the old page
|
|
36
|
+
* answering, when the 200 and the 94 elements were the page it landed on.
|
|
37
|
+
* The sign-in bounce has its own flag (AUTH-REDIRECT) and is not repeated.
|
|
38
|
+
* `main` is what the page's main area holds (collector mainRegionTag), so
|
|
39
|
+
* "41 el" alone cannot hide a main area that rendered nothing.
|
|
40
|
+
*/
|
|
41
|
+
export function crawlLine(o, counts, flags) {
|
|
42
|
+
const redirected = !o.loginRedirect && o.landedRoute !== o.requestedRoute;
|
|
43
|
+
const allFlags = [...(redirected ? [`REDIRECTED → ${o.landedRoute}`] : []), ...flags];
|
|
44
|
+
return (`${o.path} — ${o.status} · ${counts.elements} el` +
|
|
45
|
+
(counts.main ? ` · ${counts.main}` : "") +
|
|
46
|
+
(counts.missingTestid ? ` · ${counts.missingTestid} no-testid` : "") +
|
|
47
|
+
(counts.unnamed ? ` · ${counts.unnamed} unnamed` : "") +
|
|
48
|
+
(allFlags.length ? ` · ${allFlags.join(" ")}` : ""));
|
|
49
|
+
}
|
package/dist/engine/dedup.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Measuring the finding-dedup decision against the answer keys, and
|
|
3
|
-
*
|
|
2
|
+
* Measuring the finding-dedup decision against the answer keys, and the
|
|
3
|
+
* model-backed judge the store can ask beside its rule.
|
|
4
4
|
*
|
|
5
5
|
* The store's dedup rule (memory.ts, isDuplicateFinding) decides whether a
|
|
6
6
|
* newly filed finding is one it already records. Its mistakes are costly both
|
|
@@ -12,14 +12,19 @@
|
|
|
12
12
|
*
|
|
13
13
|
* The model judge asks for a verdict (same / different / unsure) and a
|
|
14
14
|
* probability through one tool call, so its answer is structured, not parsed
|
|
15
|
-
* from prose.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* from prose. Measured on these pairs it beat the rule on both apps
|
|
16
|
+
* (docs/benchmark.md), and every pair it won was a merge the rule missed, so
|
|
17
|
+
* the store asks it only about filings the rule keeps apart (DedupJudge,
|
|
18
|
+
* below): a `scenescout ci` run by default, the MCP server when told to.
|
|
19
|
+
* Nothing here touches the network; the caller's `ask` does.
|
|
18
20
|
*/
|
|
19
21
|
import { createHash } from "node:crypto";
|
|
20
22
|
import { archiveApp, classify, findingText } from "./bench.js";
|
|
21
23
|
import { MIN_FOR_A_VERDICT } from "./calibration.js";
|
|
22
|
-
import {
|
|
24
|
+
import { addUsage, dedupModeFromEnv, judgeKeyConfig, NO_USAGE } from "./ci.js";
|
|
25
|
+
import { withWatchdog } from "./dispatch.js";
|
|
26
|
+
import { findingId, isDuplicateFinding, titleSimilarity } from "./memory.js";
|
|
27
|
+
import { obj } from "./provider.js";
|
|
23
28
|
const pagesOf = (e) => [e.route, ...(e.alsoOn ?? [])];
|
|
24
29
|
const norm = (s) => (s ?? "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
25
30
|
/**
|
|
@@ -178,10 +183,12 @@ export function parseJudgement(turn) {
|
|
|
178
183
|
* Decide one pair. With no judge the rule decides, as the store does. With a
|
|
179
184
|
* judge, the model decides when it answers same or different; when it fails
|
|
180
185
|
* for any reason, or is unsure, the rule decides and the note says why, so a
|
|
181
|
-
* run never silently becomes a run of the rule.
|
|
186
|
+
* run never silently becomes a run of the rule. `ruleVerdict` is the rule's
|
|
187
|
+
* verdict when the caller already has it: the store asks only about pairs its
|
|
188
|
+
* rule keeps apart, and its rule reads more of a finding than a pair carries.
|
|
182
189
|
*/
|
|
183
|
-
export async function decideDuplicate(p, ask) {
|
|
184
|
-
const rule = () => ruleJudgement(p).verdict === "same";
|
|
190
|
+
export async function decideDuplicate(p, ask, ruleVerdict) {
|
|
191
|
+
const rule = ruleVerdict ?? (() => ruleJudgement(p).verdict === "same");
|
|
185
192
|
if (!ask)
|
|
186
193
|
return { duplicate: rule(), by: "rule" };
|
|
187
194
|
let parsed;
|
|
@@ -203,6 +210,319 @@ export async function decideDuplicate(p, ask) {
|
|
|
203
210
|
return { duplicate: rule(), by: "rule", judgement: parsed.judgement, note: "the model judge was unsure; the current rule decided" };
|
|
204
211
|
return { duplicate: parsed.judgement.verdict === "same", by: "model", judgement: parsed.judgement };
|
|
205
212
|
}
|
|
213
|
+
// ── the judge the store asks ────────────────────────────────────────────────
|
|
214
|
+
/** Calls made about one filing at most: its most alike open findings on the page. */
|
|
215
|
+
export const JUDGE_MAX_CALLS = 3;
|
|
216
|
+
/** The longest one judge call may take. */
|
|
217
|
+
export const JUDGE_CALL_MS = 15_000;
|
|
218
|
+
/**
|
|
219
|
+
* The longest the judge may spend on one filing, every call together. Under
|
|
220
|
+
* scout_finding's 60-second watchdog, so a slow provider costs a filing its
|
|
221
|
+
* judge and never the filing.
|
|
222
|
+
*/
|
|
223
|
+
export const JUDGE_FILING_MS = 40_000;
|
|
224
|
+
/** Failed calls in a row after which the judge is switched off for the rest of the run. */
|
|
225
|
+
export const JUDGE_MAX_FAILURES = 3;
|
|
226
|
+
/** The most output one judge call may produce: the answer is one short tool call (24 to 26 tokens when measured). */
|
|
227
|
+
export const JUDGE_MAX_OUTPUT_TOKENS = 2_000;
|
|
228
|
+
/**
|
|
229
|
+
* The most of a title, and of evidence, the store's judge shows the model. A
|
|
230
|
+
* filing with a pasted log for evidence still makes a question of bounded
|
|
231
|
+
* size, which a client answering the judge accepts (MAX_JUDGE_KICKOFF_CHARS).
|
|
232
|
+
*/
|
|
233
|
+
export const JUDGE_TITLE_CHARS = 500;
|
|
234
|
+
export const JUDGE_EVIDENCE_CHARS = 2_000;
|
|
235
|
+
const routeOf = (state) => state.split("#")[0];
|
|
236
|
+
/** A judge time limit as a log line says it: "15s", or "20ms" for a test's. */
|
|
237
|
+
export const durationText = (ms) => (ms < 1000 ? `${ms}ms` : `${Math.round(ms / 1000)}s`);
|
|
238
|
+
/**
|
|
239
|
+
* The model judge as the store asks it (memory.ts DuplicateJudge). It is
|
|
240
|
+
* asked only about a filing the rule keeps apart from everything stored, and
|
|
241
|
+
* only against the open findings on the filing's own page: the most alike
|
|
242
|
+
* title first, at most `maxCalls` of them, within `filingMs`. "Same" merges;
|
|
243
|
+
* anything else (different, unsure, a contradiction, a failed or slow call)
|
|
244
|
+
* leaves the rule's decision, which is to keep the filing apart. So the judge
|
|
245
|
+
* only ever adds merges the rule missed, which is where every pair it won in
|
|
246
|
+
* the measurement was (docs/benchmark.md).
|
|
247
|
+
*
|
|
248
|
+
* Each kind of fall-back is logged once, the cap once per page, and
|
|
249
|
+
* JUDGE_MAX_FAILURES failed calls in a row switch the judge off for the rest
|
|
250
|
+
* of the run. `log` reaches the operator; the caller redacts keys from it.
|
|
251
|
+
*/
|
|
252
|
+
export class DedupJudge {
|
|
253
|
+
ask;
|
|
254
|
+
o;
|
|
255
|
+
counts = { calls: 0, same: 0, different: 0, fellBack: 0, capped: 0, usage: { ...NO_USAGE }, ms: 0 };
|
|
256
|
+
failuresInARow = 0;
|
|
257
|
+
offReason = null;
|
|
258
|
+
logged = new Set();
|
|
259
|
+
maxCalls;
|
|
260
|
+
callMs;
|
|
261
|
+
filingMs;
|
|
262
|
+
now;
|
|
263
|
+
redact;
|
|
264
|
+
constructor(ask, o) {
|
|
265
|
+
this.ask = ask;
|
|
266
|
+
this.o = o;
|
|
267
|
+
this.maxCalls = o.maxCalls ?? JUDGE_MAX_CALLS;
|
|
268
|
+
this.callMs = o.callMs ?? JUDGE_CALL_MS;
|
|
269
|
+
this.filingMs = o.filingMs ?? JUDGE_FILING_MS;
|
|
270
|
+
this.now = o.now ?? Date.now;
|
|
271
|
+
this.redact = o.redact ?? ((text) => text);
|
|
272
|
+
}
|
|
273
|
+
/** What the judge has done this run. */
|
|
274
|
+
get tally() {
|
|
275
|
+
return this.counts;
|
|
276
|
+
}
|
|
277
|
+
/** Why the judge is no longer asked, or null while it is. */
|
|
278
|
+
get off() {
|
|
279
|
+
return this.offReason;
|
|
280
|
+
}
|
|
281
|
+
async judge(incoming, stored) {
|
|
282
|
+
try {
|
|
283
|
+
return await this.decide(incoming, stored);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
// decideDuplicate catches whatever a call throws, so this is a fault in this
|
|
287
|
+
// class. It would recur on every filing, so the judge stops here, and says why;
|
|
288
|
+
// the log gets the stack, the report only the message.
|
|
289
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
290
|
+
this.switchOff(`a fault in the judge: ${message}`, err instanceof Error ? err.stack : undefined);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async decide(incoming, stored) {
|
|
295
|
+
if (this.offReason)
|
|
296
|
+
return null;
|
|
297
|
+
const route = routeOf(incoming.state);
|
|
298
|
+
const candidates = stored
|
|
299
|
+
.map((x, i) => ({ x, i, alike: titleSimilarity(x.title, incoming.title) }))
|
|
300
|
+
.filter(({ x }) => x.status !== "resolved" && routeOf(x.state) === route)
|
|
301
|
+
// The most alike first; of two equally alike, the newer.
|
|
302
|
+
.sort((p, q) => q.alike - p.alike || q.i - p.i);
|
|
303
|
+
if (candidates.length > this.maxCalls) {
|
|
304
|
+
this.counts.capped += 1;
|
|
305
|
+
this.once(`cap\u0000${route}`, `dedup judge: ${candidates.length} open findings on ${route} could be the one just filed there; the judge is asked about the ` +
|
|
306
|
+
`${this.maxCalls} most alike, and the rule, which kept it apart from all of them, decides the rest ` +
|
|
307
|
+
`(at most ${this.maxCalls} calls per filing; logged once per page)`);
|
|
308
|
+
}
|
|
309
|
+
const started = this.now();
|
|
310
|
+
for (const { x } of candidates.slice(0, this.maxCalls)) {
|
|
311
|
+
if (this.offReason)
|
|
312
|
+
break;
|
|
313
|
+
const left = this.filingMs - (this.now() - started);
|
|
314
|
+
if (left <= 0) {
|
|
315
|
+
this.once("time", `dedup judge: the ${durationText(this.filingMs)} a filing may take ran out; the rule decided the pairs left (logged once)`);
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
const d = await decideDuplicate({ route, a: shown(x), b: shown(incoming) }, this.timed(Math.min(this.callMs, left)), () => false);
|
|
319
|
+
this.count(d);
|
|
320
|
+
if (d.by === "model" && d.duplicate && d.judgement)
|
|
321
|
+
return { sameAs: x.id, pSame: d.judgement.pSame };
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
/** The ask, ended at `ms` and counted. */
|
|
326
|
+
timed(ms) {
|
|
327
|
+
return async (system, tools, kickoff) => {
|
|
328
|
+
const started = this.now();
|
|
329
|
+
this.counts.calls += 1;
|
|
330
|
+
try {
|
|
331
|
+
const turn = await withWatchdog("dedup judge", this.ask(system, tools, kickoff, ms), ms, () => null);
|
|
332
|
+
if (!turn)
|
|
333
|
+
throw new Error(`no answer within ${durationText(ms)}`);
|
|
334
|
+
this.counts.usage = addUsage(this.counts.usage, turn.usage);
|
|
335
|
+
return turn;
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
this.counts.ms += this.now() - started;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
count(d) {
|
|
343
|
+
if (d.by === "model") {
|
|
344
|
+
this.failuresInARow = 0;
|
|
345
|
+
if (d.duplicate)
|
|
346
|
+
this.counts.same += 1;
|
|
347
|
+
else
|
|
348
|
+
this.counts.different += 1;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.counts.fellBack += 1;
|
|
352
|
+
if (d.failure !== "error") {
|
|
353
|
+
// Unsure, or an answer that contradicted itself: the provider answered, so neither counts towards switching off.
|
|
354
|
+
this.failuresInARow = 0;
|
|
355
|
+
this.once(d.failure ?? "unsure", `dedup judge: ${d.note ?? "no usable answer"} (later ones are counted, not logged)`);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
this.failuresInARow += 1;
|
|
359
|
+
this.once("error", `dedup judge: ${d.note ?? "a call failed"} (later failures are counted, not logged)`);
|
|
360
|
+
if (this.failuresInARow >= JUDGE_MAX_FAILURES)
|
|
361
|
+
this.switchOff(`${this.failuresInARow} failed calls in a row, the last: ${d.note ?? "no detail"}`);
|
|
362
|
+
}
|
|
363
|
+
switchOff(reason, detail) {
|
|
364
|
+
this.offReason = this.redact(reason);
|
|
365
|
+
this.o.log(this.redact(`dedup judge: switched off after ${reason}; the rule decides every filing for the rest of this run${detail ? `\n${detail}` : ""}`));
|
|
366
|
+
}
|
|
367
|
+
once(kind, line) {
|
|
368
|
+
if (this.logged.has(kind))
|
|
369
|
+
return;
|
|
370
|
+
this.logged.add(kind);
|
|
371
|
+
this.o.log(this.redact(line));
|
|
372
|
+
}
|
|
373
|
+
describe() {
|
|
374
|
+
const t = this.counts;
|
|
375
|
+
if (t.calls === 0 && !this.offReason)
|
|
376
|
+
return null;
|
|
377
|
+
const tokens = t.usage.input + t.usage.output;
|
|
378
|
+
return (`the rule, then the model judge (${this.o.label}) for filings the rule kept apart: ${t.calls} call(s), ` +
|
|
379
|
+
`${t.same} same, ${t.different} different, ${t.fellBack} left to the rule` +
|
|
380
|
+
(t.capped ? `; ${t.capped} filing(s) had more open findings on their page than the ${this.maxCalls} it asks about` : "") +
|
|
381
|
+
(tokens > 0 ? `; ${tokens.toLocaleString("en-US")} tokens` : "") +
|
|
382
|
+
`; ${(t.ms / 1000).toFixed(1)}s` +
|
|
383
|
+
(this.offReason ? `; switched off after ${this.offReason}` : ""));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** A finding as the store's judge shows it: what was filed, each field cut to a length the question can carry. */
|
|
387
|
+
function shown(f) {
|
|
388
|
+
const cut = (text, max) => (text.length <= max ? text : `${text.slice(0, max)}…`);
|
|
389
|
+
return { title: cut(f.title, JUDGE_TITLE_CHARS), category: f.category, ...(f.evidence ? { evidence: cut(f.evidence, JUDGE_EVIDENCE_CHARS) } : {}) };
|
|
390
|
+
}
|
|
391
|
+
/** Whether a client answers the judge's sampling requests: sampling with tools, and DEDUP_JUDGE_CAPABILITY. */
|
|
392
|
+
export function clientAnswersJudge(caps) {
|
|
393
|
+
return !!caps?.sampling?.tools && !!caps.experimental?.[DEDUP_JUDGE_CAPABILITY];
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* How the MCP server dedups for a run: what an attach of the run named, else
|
|
397
|
+
* DEDUP_ENV. A judge asks through the client when the client answers the
|
|
398
|
+
* judge (a `scenescout ci` run, which holds the key, so the server never
|
|
399
|
+
* does), else with a key from the server's environment; with neither it is
|
|
400
|
+
* off, and the note says why. A malformed DEDUP_ENV or DEDUP_PROVIDER_ENV is
|
|
401
|
+
* refused, naming the variable.
|
|
402
|
+
*/
|
|
403
|
+
export function planDedup(choice, env, caps) {
|
|
404
|
+
const mode = choice ?? dedupModeFromEnv(env);
|
|
405
|
+
if (mode === "rule")
|
|
406
|
+
return { mode, note: "" };
|
|
407
|
+
if (clientAnswersJudge(caps)) {
|
|
408
|
+
const label = "the CI run's model";
|
|
409
|
+
return {
|
|
410
|
+
mode,
|
|
411
|
+
via: "client",
|
|
412
|
+
label,
|
|
413
|
+
note: `\nFinding dedup: the rule, then ${label}, asked about a filing the rule keeps apart from everything recorded, against the open findings on its page.`,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const key = judgeKeyConfig(env);
|
|
417
|
+
if (!key.ok)
|
|
418
|
+
return { mode, via: "off", why: key.error, note: `\n⚠ DEDUP JUDGE OFF: ${key.error}. The rule decides duplicates.` };
|
|
419
|
+
const { provider, model, effort } = key.resolved;
|
|
420
|
+
const label = `${provider} ${model}, effort ${effort}`;
|
|
421
|
+
return {
|
|
422
|
+
mode,
|
|
423
|
+
via: "key",
|
|
424
|
+
resolved: key.resolved,
|
|
425
|
+
key: key.key,
|
|
426
|
+
label,
|
|
427
|
+
note: `\nFinding dedup: the rule, then a model judge (${label}), asked about a filing the rule keeps apart from everything recorded, against the open findings on its page. ` +
|
|
428
|
+
`Each pair it is asked about (titles, categories, evidence and the page's path) is sent to ${provider}.`,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
// ── the judge through an MCP client ─────────────────────────────────────────
|
|
432
|
+
/**
|
|
433
|
+
* The client capability, under `experimental`, by which a client says it
|
|
434
|
+
* answers the dedup judge's sampling requests. `scenescout ci` declares it:
|
|
435
|
+
* the server's judge then asks the run's model through the run, and the key
|
|
436
|
+
* never enters the server's process. No other client is sent a sampling
|
|
437
|
+
* request.
|
|
438
|
+
*/
|
|
439
|
+
export const DEDUP_JUDGE_CAPABILITY = "scenescout/dedup-judge";
|
|
440
|
+
/**
|
|
441
|
+
* The longest question a client answering the judge accepts. The store's judge
|
|
442
|
+
* cuts titles and evidence (JUDGE_TITLE_CHARS, JUDGE_EVIDENCE_CHARS), so its
|
|
443
|
+
* questions always fit; a longer one is refused, and counted as a failed call.
|
|
444
|
+
*/
|
|
445
|
+
export const MAX_JUDGE_KICKOFF_CHARS = 20_000;
|
|
446
|
+
/** The sampling request (MCP sampling/createMessage, with tools) that carries one judge call to the client. */
|
|
447
|
+
export function judgeSamplingParams(system, tools, kickoff) {
|
|
448
|
+
return {
|
|
449
|
+
systemPrompt: system,
|
|
450
|
+
messages: [{ role: "user", content: { type: "text", text: kickoff } }],
|
|
451
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: { ...t.parameters, type: "object" } })),
|
|
452
|
+
toolChoice: { mode: "required" },
|
|
453
|
+
maxTokens: JUDGE_MAX_OUTPUT_TOKENS,
|
|
454
|
+
includeContext: "none",
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/** An Ask that puts the judge's question to the client. `createMessage` is the server's sampling request. */
|
|
458
|
+
export function samplingAsk(createMessage, callMs = JUDGE_CALL_MS) {
|
|
459
|
+
return async (system, tools, kickoff, limitMs) => turnFromSampling(await createMessage(judgeSamplingParams(system, tools, kickoff), { timeout: Math.min(callMs, limitMs ?? callMs) }));
|
|
460
|
+
}
|
|
461
|
+
/** The client's answer read back as a turn, for parseJudgement. Usage is the client's to count, so the turn carries none. */
|
|
462
|
+
export function turnFromSampling(result) {
|
|
463
|
+
const r = obj(result);
|
|
464
|
+
const content = r ? (Array.isArray(r.content) ? r.content : [r.content]) : [];
|
|
465
|
+
const blocks = content.map(obj).filter((b) => b !== null);
|
|
466
|
+
const calls = blocks
|
|
467
|
+
.filter((b) => b.type === "tool_use")
|
|
468
|
+
.map((b, i) => ({ id: typeof b.id === "string" ? b.id : `call_${i}`, name: typeof b.name === "string" ? b.name : "", input: b.input }));
|
|
469
|
+
const text = blocks
|
|
470
|
+
.filter((b) => b.type === "text")
|
|
471
|
+
.map((b) => String(b.text ?? ""))
|
|
472
|
+
.join("\n")
|
|
473
|
+
.trim();
|
|
474
|
+
const note = calls.length > 0
|
|
475
|
+
? undefined
|
|
476
|
+
: r?.stopReason === "maxTokens"
|
|
477
|
+
? "the model's reply was cut at its output limit"
|
|
478
|
+
: text
|
|
479
|
+
? `the model answered without calling a tool: ${text.slice(0, 200)}`
|
|
480
|
+
: undefined;
|
|
481
|
+
return { text, calls, usage: { ...NO_USAGE }, ...(note ? { note } : {}) };
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* The client's side: the text of a sampling request shaped as the judge's
|
|
485
|
+
* question, checked for shape and size only. A client answering the judge
|
|
486
|
+
* sends this text on as given, under its own JUDGE_SYSTEM, JUDGE_TOOL and
|
|
487
|
+
* output cap, so the server cannot choose the system prompt, the tools or how
|
|
488
|
+
* much the model may write.
|
|
489
|
+
*/
|
|
490
|
+
export function judgeKickoffOf(params) {
|
|
491
|
+
const p = obj(params);
|
|
492
|
+
if (!p)
|
|
493
|
+
return { ok: false, error: "the request has no parameters" };
|
|
494
|
+
const tools = Array.isArray(p.tools) ? p.tools.map(obj) : [];
|
|
495
|
+
if (tools.length !== 1 || tools[0]?.name !== JUDGE_TOOL.name)
|
|
496
|
+
return { ok: false, error: `the request does not offer exactly the ${JUDGE_TOOL.name} tool` };
|
|
497
|
+
const messages = Array.isArray(p.messages) ? p.messages.map(obj) : [];
|
|
498
|
+
if (messages.length !== 1 || messages[0]?.role !== "user")
|
|
499
|
+
return { ok: false, error: "the request is not one user message" };
|
|
500
|
+
const content = messages[0].content;
|
|
501
|
+
const blocks = (Array.isArray(content) ? content : [content]).map(obj);
|
|
502
|
+
const text = blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0].text === "string" ? blocks[0].text : "";
|
|
503
|
+
if (!text.trim())
|
|
504
|
+
return { ok: false, error: "the message is not one block of text" };
|
|
505
|
+
if (text.length > MAX_JUDGE_KICKOFF_CHARS)
|
|
506
|
+
return { ok: false, error: `the question is longer than ${MAX_JUDGE_KICKOFF_CHARS} characters` };
|
|
507
|
+
return { ok: true, kickoff: text };
|
|
508
|
+
}
|
|
509
|
+
/** The client's answer: the judge's turn as a sampling result, its tool call as tool_use content. */
|
|
510
|
+
export function samplingResultOf(turn, model) {
|
|
511
|
+
const content = [];
|
|
512
|
+
for (const c of turn.calls) {
|
|
513
|
+
const input = obj(c.input);
|
|
514
|
+
if (input && !c.argsError)
|
|
515
|
+
content.push({ type: "tool_use", id: c.id, name: c.name, input });
|
|
516
|
+
else
|
|
517
|
+
content.push({ type: "text", text: `${c.name || "a tool"} was called with arguments that could not be read: ${c.argsError ?? "not an object"}` });
|
|
518
|
+
}
|
|
519
|
+
const said = [turn.text, turn.note].filter((s) => !!s && s.trim()).join("\n");
|
|
520
|
+
if (said)
|
|
521
|
+
content.push({ type: "text", text: said });
|
|
522
|
+
if (content.length === 0)
|
|
523
|
+
content.push({ type: "text", text: "(no answer)" });
|
|
524
|
+
return { model, role: "assistant", content, stopReason: content.some((b) => b.type === "tool_use") ? "toolUse" : "endTurn" };
|
|
525
|
+
}
|
|
206
526
|
/**
|
|
207
527
|
* Accuracy, Brier and ECE of a decider over labelled pairs. `judgements[i]`
|
|
208
528
|
* answers `labels[i]`; null is a judge that gave no usable answer. The
|