@jkudish/jev-mcp 0.5.0 → 0.6.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 +12 -0
- package/README.md +137 -9
- package/dist/index.js +258 -149
- package/dist/index.js.map +1 -1
- package/dist/lib.d.ts +20 -0
- package/dist/lib.js +24 -0
- package/dist/lib.js.map +1 -1
- package/dist/provider.d.ts +5 -1
- package/dist/provider.js +371 -59
- package/dist/provider.js.map +1 -1
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -19,18 +19,28 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
19
19
|
import { choice, noul, score } from "@typesafe-ai/sdk";
|
|
20
20
|
import { z } from "zod";
|
|
21
21
|
import { createRequire } from "node:module";
|
|
22
|
-
import { classificationDecision, claimAction, contradictsRecommendation, DECIDE_ESCAPE_HATCHES, DEFAULT_COMPOSITE_FLOOR, ensureUniqueIds, existsVerdict, hasNonEmptyEvidence, marginOf, MAX_CANDIDATES_DECIDE, MAX_CLASSES, MAX_ITEM_CHARS, MAX_ITEMS, MAX_REQUIREMENTS, MAX_CANDIDATES, MAX_CANDIDATE_CHARS, MAX_GATE_CLAIMS, MAX_GATE_EVIDENCE_CHARS, MAX_GATE_EVIDENCE_ITEMS, MAX_CLAIM_CHARS, MAX_REVIEW_DOC_CHARS, normalizeEvidence, rankCandidates, requireCompleteContext, resolvePolicyThresholds, REVIEW_WEIGHTS, reviewAction, reviewComposite, COMPARE_RELATIONS, ASPECT_RELATIONS, MAX_COMPARE_ASPECTS, MAX_EXTRACT_CANDIDATES, MAX_EXTRACT_CANDIDATE_CHARS, MAX_EXTRACT_FIELDS, MAX_EXTRACT_TOTAL_CHARS, MAX_RERANK_CANDIDATES, MAX_RERANK_TOTAL_CHARS, REGEX_TIMEOUT_MS, rerankByScore, RELATION_TO_VERDICT, screenRecommendation, truncate, verifyAction, VERIFY_CLAIM_CRITERIA, worstAction, } from "./lib.js";
|
|
22
|
+
import { classificationDecision, claimAction, contradictsRecommendation, DECIDE_ESCAPE_HATCHES, DEFAULT_COMPOSITE_FLOOR, ensureUniqueIds, existsVerdict, hasNonEmptyEvidence, isRecord, marginOf, MAX_CANDIDATES_DECIDE, MAX_CLASSES, MAX_ITEM_CHARS, MAX_ITEMS, MAX_REQUIREMENTS, MAX_CANDIDATES, MAX_CANDIDATE_CHARS, PROBABILITY_SUM_TOLERANCE, SCORE_MEAN_TOLERANCE, MAX_GATE_CLAIMS, MAX_GATE_EVIDENCE_CHARS, MAX_GATE_EVIDENCE_ITEMS, MAX_CLAIM_CHARS, MAX_REVIEW_DOC_CHARS, normalizeEvidence, rankCandidates, requireCompleteContext, resolvePolicyThresholds, REVIEW_WEIGHTS, reviewAction, reviewComposite, COMPARE_RELATIONS, ASPECT_RELATIONS, MAX_COMPARE_ASPECTS, MAX_EXTRACT_CANDIDATES, MAX_EXTRACT_CANDIDATE_CHARS, MAX_EXTRACT_FIELDS, MAX_EXTRACT_TOTAL_CHARS, MAX_RERANK_CANDIDATES, MAX_RERANK_TOTAL_CHARS, REGEX_TIMEOUT_MS, rerankByScore, RELATION_TO_VERDICT, screenRecommendation, truncate, verifyAction, VERIFY_CLAIM_CRITERIA, worstAction, } from "./lib.js";
|
|
23
23
|
const MODEL = process.env.JEV_MCP_MODEL ?? "jev-latest";
|
|
24
24
|
// Resolved at runtime so the MCP handshake version always matches the package.
|
|
25
25
|
const { version: packageVersion } = createRequire(import.meta.url)("../package.json");
|
|
26
26
|
const server = new McpServer({ name: "jev-mcp", version: packageVersion });
|
|
27
27
|
import { askJev as askProvider } from "./provider.js";
|
|
28
|
-
async function askJev(state, questions) {
|
|
29
|
-
|
|
28
|
+
async function askJev(state, questions, signal) {
|
|
29
|
+
const result = await askProvider(state, questions, MODEL, signal);
|
|
30
|
+
// The typesafe SDK transport returns unchecked `answers: any`; a null or
|
|
31
|
+
// non-object envelope must not crash tools that index it. Normalize once
|
|
32
|
+
// here so every tool takes the same {}-to-invalid_response path. The
|
|
33
|
+
// compatible transport already rejects such envelopes itself.
|
|
34
|
+
return { ...result, answers: (isRecord(result.answers) ? result.answers : {}) };
|
|
30
35
|
}
|
|
31
36
|
const text = (payload) => ({
|
|
32
37
|
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
33
38
|
});
|
|
39
|
+
// Tool inputs are strict: unknown top-level keys are rejected instead of
|
|
40
|
+
// silently stripped, so a client typos-as-error rather than typos-as-default.
|
|
41
|
+
// Nested fixed-shape objects are strict for the same reason; `context`
|
|
42
|
+
// deliberately stays an open record.
|
|
43
|
+
const strictShape = (shape) => z.object(shape).strict();
|
|
34
44
|
const evidenceSchema = z.union([
|
|
35
45
|
z.string().describe("A single evidence document."),
|
|
36
46
|
z
|
|
@@ -38,12 +48,13 @@ const evidenceSchema = z.union([
|
|
|
38
48
|
id: z.string().optional().describe("Short identifier for this evidence item."),
|
|
39
49
|
text: z.string().describe("The evidence text."),
|
|
40
50
|
})
|
|
51
|
+
.strict()
|
|
41
52
|
.describe("A single evidence item."),
|
|
42
53
|
z
|
|
43
54
|
.array(z.object({
|
|
44
55
|
id: z.string().optional().describe("Short identifier for this evidence item (e.g. 'site-html', 'rfc-4.1.3')."),
|
|
45
56
|
text: z.string().describe("The evidence text."),
|
|
46
|
-
}))
|
|
57
|
+
}).strict())
|
|
47
58
|
.min(1)
|
|
48
59
|
.describe("Multiple evidence items; each claim is also matched to the item it rests on."),
|
|
49
60
|
]);
|
|
@@ -51,7 +62,7 @@ const candidatesSchema = z
|
|
|
51
62
|
.array(z.object({
|
|
52
63
|
id: z.string().optional().describe("Short identifier for this candidate (e.g. a file path, note name, or line id)."),
|
|
53
64
|
text: z.string().describe("The candidate's text."),
|
|
54
|
-
}))
|
|
65
|
+
}).strict())
|
|
55
66
|
.min(1)
|
|
56
67
|
.max(MAX_CANDIDATES)
|
|
57
68
|
.describe(`Candidates to search. Up to ${MAX_CANDIDATES} in one call; texts are truncated at ${MAX_CANDIDATE_CHARS} chars.`);
|
|
@@ -65,7 +76,7 @@ server.registerTool("jev_verify", {
|
|
|
65
76
|
"and whether the verdict stands on its own (auto) or needs human review. " +
|
|
66
77
|
"Pattern: docs.typesafe.ai/cookbooks/citation_check. Pass reports, PR descriptions, or agent briefs as claims " +
|
|
67
78
|
"and their cited sources, diffs, or documents as evidence.",
|
|
68
|
-
inputSchema: {
|
|
79
|
+
inputSchema: strictShape({
|
|
69
80
|
claims: z.array(z.string()).min(1).describe("Claims to verify, e.g. individual factual statements from a report."),
|
|
70
81
|
evidence: evidenceSchema,
|
|
71
82
|
auto_accept: z
|
|
@@ -74,8 +85,8 @@ server.registerTool("jev_verify", {
|
|
|
74
85
|
.max(1)
|
|
75
86
|
.optional()
|
|
76
87
|
.describe("Verdicts at or above this confidence stand automatically; below it they are flagged 'review'. Default 0.8."),
|
|
77
|
-
},
|
|
78
|
-
}, async ({ claims, evidence: rawEvidence, auto_accept }) => {
|
|
88
|
+
}),
|
|
89
|
+
}, async ({ claims, evidence: rawEvidence, auto_accept }, extra) => {
|
|
79
90
|
const autoAccept = auto_accept ?? 0.8;
|
|
80
91
|
const evidenceItems = typeof rawEvidence === "string"
|
|
81
92
|
? [{ id: "evidence", text: rawEvidence }]
|
|
@@ -102,20 +113,42 @@ server.registerTool("jev_verify", {
|
|
|
102
113
|
claims: claimItems,
|
|
103
114
|
evidence,
|
|
104
115
|
};
|
|
105
|
-
const { answers, usage, provider, model } = await askJev(state, questions);
|
|
116
|
+
const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
|
|
106
117
|
const results = claimItems.map((claim) => {
|
|
107
118
|
const relation = answers[`relation_${claim.id}`];
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
119
|
+
const validated = validateChoiceAnswer(relation, Object.keys(RELATION_TO_VERDICT));
|
|
120
|
+
// source_* is optional auxiliary information, requested only for multiple
|
|
121
|
+
// evidence items. Its absence does not invalidate the relation verdict,
|
|
122
|
+
// but a present source must name a supplied evidence id (or "none").
|
|
123
|
+
const sourceKeys = [...evidence.map((e) => e.id), "none"];
|
|
124
|
+
const source = validateChoiceAnswer(answers[`source_${claim.id}`], sourceKeys);
|
|
125
|
+
const valid = validated && Object.hasOwn(RELATION_TO_VERDICT, validated.choice) &&
|
|
126
|
+
(relation.confidence == null || validated.confidence !== null);
|
|
127
|
+
const confidence = valid ? validated.confidence : null;
|
|
128
|
+
if (!valid) {
|
|
129
|
+
// A missing or malformed relation must not masquerade as an unsupported
|
|
130
|
+
// claim: surface it as invalid so callers can tell protocol failure
|
|
131
|
+
// apart from a real verdict.
|
|
132
|
+
return {
|
|
133
|
+
id: claim.id,
|
|
134
|
+
claim: claim.text,
|
|
135
|
+
verdict: "unknown",
|
|
136
|
+
probabilities: null,
|
|
137
|
+
confidence: null,
|
|
138
|
+
status: "invalid_response",
|
|
139
|
+
action: "review",
|
|
140
|
+
supporting_evidence: null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const verdict = RELATION_TO_VERDICT[relation.choice];
|
|
111
144
|
return {
|
|
112
145
|
id: claim.id,
|
|
113
146
|
claim: claim.text,
|
|
114
147
|
verdict,
|
|
115
|
-
probabilities: relation
|
|
148
|
+
probabilities: relation.probabilities ?? null,
|
|
116
149
|
confidence,
|
|
117
150
|
action: confidence === null ? "review" : verifyAction(confidence, autoAccept),
|
|
118
|
-
supporting_evidence: source
|
|
151
|
+
supporting_evidence: source && source.choice !== "none" ? source.choice : null,
|
|
119
152
|
};
|
|
120
153
|
});
|
|
121
154
|
return text({
|
|
@@ -142,7 +175,7 @@ server.registerTool("jev_screen", {
|
|
|
142
175
|
"instructions aimed at an AI agent (prompt injection), whether it has substantive content, and (when a purpose " +
|
|
143
176
|
"is given) whether it is relevant to the task. Returns a recommendation: pass | review | block | skip. " +
|
|
144
177
|
"Pattern: docs.typesafe.ai/cookbooks/llm_guardrails.",
|
|
145
|
-
inputSchema: {
|
|
178
|
+
inputSchema: strictShape({
|
|
146
179
|
text: z.string().min(1).describe("The content to screen, e.g. a fetched web page or pasted document."),
|
|
147
180
|
purpose: z
|
|
148
181
|
.string()
|
|
@@ -150,8 +183,8 @@ server.registerTool("jev_screen", {
|
|
|
150
183
|
.describe("What the consuming agent is trying to do; enables a relevance judgment and the 'skip' action."),
|
|
151
184
|
block_at: z.number().min(0).max(1).optional().describe("Injection probability at or above which content is blocked. Default 0.75."),
|
|
152
185
|
review_at: z.number().min(0).max(1).optional().describe("Injection probability at or above which content is flagged for review. Default 0.25."),
|
|
153
|
-
},
|
|
154
|
-
}, async ({ text: content, purpose, block_at, review_at }) => {
|
|
186
|
+
}),
|
|
187
|
+
}, async ({ text: content, purpose, block_at, review_at }, extra) => {
|
|
155
188
|
const blockAt = block_at ?? 0.75;
|
|
156
189
|
const reviewAt = review_at ?? 0.25;
|
|
157
190
|
const questions = {
|
|
@@ -171,11 +204,25 @@ server.registerTool("jev_screen", {
|
|
|
171
204
|
});
|
|
172
205
|
}
|
|
173
206
|
const state = { content, purpose: purpose ?? null };
|
|
174
|
-
const { answers, usage, provider, model } = await askJev(state, questions);
|
|
175
|
-
const injection = answers.injection
|
|
176
|
-
const substance = answers.substance
|
|
177
|
-
const relevance = purpose ? (answers.relevance
|
|
178
|
-
|
|
207
|
+
const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
|
|
208
|
+
const injection = validateNoulAnswer(answers.injection);
|
|
209
|
+
const substance = validateNoulAnswer(answers.substance);
|
|
210
|
+
const relevance = purpose ? validateNoulAnswer(answers.relevance) : undefined;
|
|
211
|
+
if (injection === null || substance === null || (purpose && relevance === null)) {
|
|
212
|
+
// A screening tool must fail closed: a missing or malformed answer must
|
|
213
|
+
// not be treated as a clean bill of health (injection ?? 0 would pass).
|
|
214
|
+
return text({
|
|
215
|
+
tool: "jev_screen",
|
|
216
|
+
model: model,
|
|
217
|
+
provider,
|
|
218
|
+
status: "invalid_response",
|
|
219
|
+
probabilities: { injection: injection ?? null, substance: substance ?? null, relevance: relevance ?? null },
|
|
220
|
+
thresholds: { block_at: blockAt, review_at: reviewAt },
|
|
221
|
+
recommendation: { action: "review", reason: "missing or malformed answers; cannot screen safely" },
|
|
222
|
+
usage,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const recommendation = screenRecommendation({ injection, relevance: relevance ?? undefined, substance, blockAt, reviewAt });
|
|
179
226
|
return text({
|
|
180
227
|
tool: "jev_screen",
|
|
181
228
|
model: model,
|
|
@@ -196,12 +243,12 @@ server.registerTool("jev_find", {
|
|
|
196
243
|
"any candidate addresses the query at all (so a confident 'top hit' cannot masquerade as an answer). " +
|
|
197
244
|
"Pattern: docs.typesafe.ai/cookbooks/semantic_find. Use for 'which file/note/line covers X' across up to " +
|
|
198
245
|
`${MAX_CANDIDATES} candidates.`,
|
|
199
|
-
inputSchema: {
|
|
246
|
+
inputSchema: strictShape({
|
|
200
247
|
query: z.string().min(1).describe("What you are looking for, in natural language."),
|
|
201
248
|
candidates: candidatesSchema,
|
|
202
249
|
top_k: z.number().int().min(1).max(50).optional().describe("How many ranked candidates to return. Default 5."),
|
|
203
|
-
},
|
|
204
|
-
}, async ({ query, candidates: rawCandidates, top_k }) => {
|
|
250
|
+
}),
|
|
251
|
+
}, async ({ query, candidates: rawCandidates, top_k }, extra) => {
|
|
205
252
|
const topK = top_k ?? 5;
|
|
206
253
|
const { items: candidates } = ensureUniqueIds(rawCandidates.map((c) => ({ id: c.id ?? "", text: truncate(c.text, MAX_CANDIDATE_CHARS) })), "candidate");
|
|
207
254
|
const criteria = Object.fromEntries(candidates.map((c) => [c.id, null]));
|
|
@@ -213,10 +260,26 @@ server.registerTool("jev_find", {
|
|
|
213
260
|
}),
|
|
214
261
|
};
|
|
215
262
|
const state = { query, candidates };
|
|
216
|
-
const { answers, usage, provider, model } = await askJev(state, questions);
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
|
|
263
|
+
const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
|
|
264
|
+
const exists = validateNoulAnswer(answers.exists);
|
|
265
|
+
const best = validateChoiceAnswer(answers.best, candidates.map((c) => c.id));
|
|
266
|
+
if (exists === null || best === null) {
|
|
267
|
+
// A missing exists/best answer must not be read as "no match" (exists ?? 0)
|
|
268
|
+
// or "no ranking": surface the protocol failure instead.
|
|
269
|
+
return text({
|
|
270
|
+
tool: "jev_find",
|
|
271
|
+
model: model,
|
|
272
|
+
provider,
|
|
273
|
+
query,
|
|
274
|
+
status: "invalid_response",
|
|
275
|
+
exists: exists ?? null,
|
|
276
|
+
exists_verdict: null,
|
|
277
|
+
top: [],
|
|
278
|
+
reason: "missing or malformed best or exists answer; cannot rank safely",
|
|
279
|
+
usage,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const ranked = rankCandidates(candidates, best.probabilities).slice(0, topK);
|
|
220
283
|
return text({
|
|
221
284
|
tool: "jev_find",
|
|
222
285
|
model: model,
|
|
@@ -239,14 +302,14 @@ server.registerTool("jev_classify", {
|
|
|
239
302
|
"and an auto-versus-review decision. Auto requires both a high top probability (default 0.85) and a " +
|
|
240
303
|
"clear margin (default 0.50); everything else is flagged for review. Include a manual_review class " +
|
|
241
304
|
"in the catalog if you want an explicit escape hatch; the tool never invents one.",
|
|
242
|
-
inputSchema: {
|
|
305
|
+
inputSchema: strictShape({
|
|
243
306
|
items: z
|
|
244
|
-
.array(z.object({ id: z.string().optional(), text: z.string() }))
|
|
307
|
+
.array(z.object({ id: z.string().optional(), text: z.string() }).strict())
|
|
245
308
|
.min(1)
|
|
246
309
|
.max(MAX_ITEMS)
|
|
247
310
|
.describe(`Items to classify. Text is truncated at ${MAX_ITEM_CHARS} characters; send bounded excerpts, not whole documents.`),
|
|
248
311
|
classes: z
|
|
249
|
-
.array(z.object({ id: z.string().optional(), description: z.string() }))
|
|
312
|
+
.array(z.object({ id: z.string().optional(), description: z.string() }).strict())
|
|
250
313
|
.min(2)
|
|
251
314
|
.max(MAX_CLASSES)
|
|
252
315
|
.describe("Shared class catalog. Strong descriptions carry the decision: a precise definition, " +
|
|
@@ -258,8 +321,8 @@ server.registerTool("jev_classify", {
|
|
|
258
321
|
.describe("Shared context available to every item's judgment: policies, catalogs, anything stable."),
|
|
259
322
|
auto_accept: z.number().min(0).max(1).optional().describe("Minimum top probability for auto. Default 0.85."),
|
|
260
323
|
minimum_margin: z.number().min(0).max(1).optional().describe("Minimum winner-to-runner-up gap for auto. Default 0.5."),
|
|
261
|
-
},
|
|
262
|
-
}, async ({ items: rawItems, classes: rawClasses, purpose, context, auto_accept, minimum_margin }) => {
|
|
324
|
+
}),
|
|
325
|
+
}, async ({ items: rawItems, classes: rawClasses, purpose, context, auto_accept, minimum_margin }, extra) => {
|
|
263
326
|
const autoAccept = auto_accept ?? 0.85;
|
|
264
327
|
const minMargin = minimum_margin ?? 0.5;
|
|
265
328
|
// Preserve caller IDs exactly; use opaque internal keys (i0/c0) for the
|
|
@@ -303,23 +366,11 @@ server.registerTool("jev_classify", {
|
|
|
303
366
|
for (const item of items) {
|
|
304
367
|
questions[item.key] = choice({ task: "Which class does this item belong to?", item: { id: item.key, text: item.text } }, criteria);
|
|
305
368
|
}
|
|
306
|
-
const { answers, usage, provider, model } = await askJev(state, questions);
|
|
369
|
+
const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
|
|
307
370
|
const keyToExternal = new Map(classes.map((c) => [c.key, c.external]));
|
|
308
371
|
const results = items.map((item) => {
|
|
309
|
-
const answer = answers[item.key];
|
|
310
|
-
|
|
311
|
-
const probabilities = answer?.probabilities ?? {};
|
|
312
|
-
const keys = Object.keys(probabilities);
|
|
313
|
-
const values = Object.values(probabilities);
|
|
314
|
-
const sum = values.reduce((a, b) => a + b, 0);
|
|
315
|
-
const valid = answer &&
|
|
316
|
-
typeof answer.choice === "string" &&
|
|
317
|
-
expected.has(answer.choice) &&
|
|
318
|
-
keys.length >= expected.size &&
|
|
319
|
-
keys.every((k) => expected.has(k)) &&
|
|
320
|
-
values.every((p) => Number.isFinite(p) && p >= 0 && p <= 1) &&
|
|
321
|
-
Math.abs(sum - 1) <= 0.01;
|
|
322
|
-
if (!valid) {
|
|
372
|
+
const answer = validateChoiceAnswer(answers[item.key], classes.map((c) => c.key));
|
|
373
|
+
if (!answer) {
|
|
323
374
|
return {
|
|
324
375
|
id: item.external,
|
|
325
376
|
status: "invalid_response",
|
|
@@ -330,14 +381,15 @@ server.registerTool("jev_classify", {
|
|
|
330
381
|
decision: "review",
|
|
331
382
|
};
|
|
332
383
|
}
|
|
384
|
+
const values = Object.values(answer.probabilities);
|
|
333
385
|
const ranked = values.slice().sort((a, b) => b - a);
|
|
334
386
|
const margin = ranked.length >= 2 ? ranked[0] - ranked[1] : 0;
|
|
335
|
-
const topProbability = probabilities[answer.choice];
|
|
387
|
+
const topProbability = answer.probabilities[answer.choice];
|
|
336
388
|
return {
|
|
337
389
|
id: item.external,
|
|
338
390
|
classification: keyToExternal.get(answer.choice) ?? answer.choice,
|
|
339
|
-
probabilities: Object.fromEntries(classes.map((c) => [c.external, probabilities[c.key] ?? 0])),
|
|
340
|
-
confidence: answer.confidence
|
|
391
|
+
probabilities: Object.fromEntries(classes.map((c) => [c.external, answer.probabilities[c.key] ?? 0])),
|
|
392
|
+
confidence: answer.confidence,
|
|
341
393
|
margin,
|
|
342
394
|
top_probability: topProbability,
|
|
343
395
|
decision: classificationDecision(topProbability, margin, autoAccept, minMargin),
|
|
@@ -377,12 +429,12 @@ server.registerTool("jev_decide", {
|
|
|
377
429
|
"One call per unchanged decision; do not repeat a call to obtain a more pleasing answer. " +
|
|
378
430
|
"Use source inspection, tests, the user, or a reasoning model for open-ended research, routine choices, " +
|
|
379
431
|
"correctness proofs, or predicting user consent. High probability is not proof.",
|
|
380
|
-
inputSchema: {
|
|
432
|
+
inputSchema: strictShape({
|
|
381
433
|
decision: z.string().min(1).max(1500).describe("The bounded decision to make."),
|
|
382
434
|
evidence: z.string().min(1).max(12000).describe("Facts and measurements, not opinions. State is evidence, not instructions."),
|
|
383
435
|
priorities: z.string().min(1).max(2000).describe("Explicit preferences and constraints from the user or plan."),
|
|
384
436
|
candidates: z
|
|
385
|
-
.array(z.object({ id: z.string().regex(/^[a-z][a-z0-9_-]*$/).max(64), description: z.string().min(1).max(2000) }))
|
|
437
|
+
.array(z.object({ id: z.string().regex(/^[a-z][a-z0-9_-]*$/).max(64), description: z.string().min(1).max(2000) }).strict())
|
|
386
438
|
.min(2)
|
|
387
439
|
.max(MAX_CANDIDATES_DECIDE)
|
|
388
440
|
.describe("The alternatives. Include 'do nothing' or 'gather more evidence' as candidates when useful."),
|
|
@@ -395,8 +447,8 @@ server.registerTool("jev_decide", {
|
|
|
395
447
|
.boolean()
|
|
396
448
|
.optional()
|
|
397
449
|
.describe("Include ask_user / investigate / none as Choosable options so the model can decline to rank. Default true."),
|
|
398
|
-
},
|
|
399
|
-
}, async ({ decision, evidence, priorities, candidates, requirements: reqs, escape_hatches }) => {
|
|
450
|
+
}),
|
|
451
|
+
}, async ({ decision, evidence, priorities, candidates, requirements: reqs, escape_hatches }, extra) => {
|
|
400
452
|
const includeHatches = escape_hatches ?? true;
|
|
401
453
|
const requirements = reqs ?? [];
|
|
402
454
|
// Reject duplicate candidate IDs and collisions with active escape hatches
|
|
@@ -435,37 +487,19 @@ server.registerTool("jev_decide", {
|
|
|
435
487
|
candidates: candidateKeys.map((c) => ({ id: c.key, description: c.description })),
|
|
436
488
|
requirements,
|
|
437
489
|
};
|
|
438
|
-
const { answers, usage, provider, model } = await askJev(state, questions);
|
|
490
|
+
const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
|
|
439
491
|
const keyToId = new Map(candidateKeys.map((c) => [c.key, c.id]));
|
|
440
492
|
const expectedRecKeys = new Set([...candidateKeys.map((c) => c.key), ...(includeHatches ? Object.keys(DECIDE_ESCAPE_HATCHES) : [])]);
|
|
441
493
|
const expectedCheckKeys = new Set(["supported", "contradicted", "unknown"]);
|
|
442
|
-
// classify-grade validation
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
return null;
|
|
448
|
-
const probabilities = answer.probabilities ?? {};
|
|
449
|
-
const keys = Object.keys(probabilities);
|
|
450
|
-
const values = Object.values(probabilities);
|
|
451
|
-
if (keys.length !== expected.size ||
|
|
452
|
-
!keys.every((k) => expected.has(k)) ||
|
|
453
|
-
!values.every((p) => Number.isFinite(p) && p >= 0 && p <= 1) ||
|
|
454
|
-
Math.abs(values.reduce((a, b) => a + b, 0) - 1) > 0.01 ||
|
|
455
|
-
// Choice contract: the chosen option must be the argmax.
|
|
456
|
-
probabilities[answer.choice] < Math.max(...values) - 1e-9)
|
|
457
|
-
return null;
|
|
458
|
-
const rawConfidence = answer.confidence;
|
|
459
|
-
const confidence = typeof rawConfidence === "number" && Number.isFinite(rawConfidence) && rawConfidence >= 0 && rawConfidence <= 1
|
|
460
|
-
? rawConfidence
|
|
461
|
-
: null;
|
|
462
|
-
return { choice: answer.choice, confidence, probabilities };
|
|
463
|
-
};
|
|
464
|
-
const rec = validateChoice(answers.recommendation, expectedRecKeys);
|
|
494
|
+
// classify-grade validation via the shared validateChoiceAnswer: exact
|
|
495
|
+
// keys, finite [0,1] probabilities summing to one within the shared
|
|
496
|
+
// tolerance, chosen key is the argmax, confidence finite or null.
|
|
497
|
+
// Malformed responses are never semantic outcomes.
|
|
498
|
+
const rec = validateChoiceAnswer(answers.recommendation, expectedRecKeys);
|
|
465
499
|
const recProbabilities = rec?.probabilities ?? {};
|
|
466
500
|
const recommendedKey = rec?.choice ?? null;
|
|
467
501
|
const checks = candidateKeys.flatMap((c, i) => requirements.map((_, j) => {
|
|
468
|
-
const answer =
|
|
502
|
+
const answer = validateChoiceAnswer(answers[`check_${i}_${j}`], expectedCheckKeys);
|
|
469
503
|
return { candidate: c.id, requirement: j, answer: answer?.choice ?? "invalid_response" };
|
|
470
504
|
}));
|
|
471
505
|
const contradicted = recommendedKey && candidateKeySet.has(recommendedKey)
|
|
@@ -501,12 +535,12 @@ server.registerTool("jev_rerank", {
|
|
|
501
535
|
"every candidate so the full ordering survives. TypeSafe's rerank cookbook reports that on the CLERC benchmark " +
|
|
502
536
|
"this pattern lifted top-1 from 5% to 18% and top-10 from 38% to 62% (docs.typesafe.ai/cookbooks). " +
|
|
503
537
|
`Use for retrieval ordering, dedup triage, or feed ranking across up to ${MAX_RERANK_CANDIDATES} candidates.`,
|
|
504
|
-
inputSchema: {
|
|
538
|
+
inputSchema: strictShape({
|
|
505
539
|
query: z.string().min(1).max(2000).describe("What relevance is measured against, in natural language."),
|
|
506
540
|
candidates: candidatesSchema,
|
|
507
541
|
top_k: z.number().int().min(1).max(250).optional().describe("How many ranked candidates to return. Default: all."),
|
|
508
|
-
},
|
|
509
|
-
}, async ({ query, candidates: rawCandidates, top_k }) => {
|
|
542
|
+
}),
|
|
543
|
+
}, async ({ query, candidates: rawCandidates, top_k }, extra) => {
|
|
510
544
|
const topK = top_k ?? null;
|
|
511
545
|
// Caller IDs are preserved verbatim; opaque wire keys (classify pattern).
|
|
512
546
|
// Duplicate supplied IDs are rejected rather than silently renamed, and
|
|
@@ -549,7 +583,7 @@ server.registerTool("jev_rerank", {
|
|
|
549
583
|
false: "The candidate is about a different subject, or only shares vocabulary with the query",
|
|
550
584
|
});
|
|
551
585
|
});
|
|
552
|
-
const { answers, usage, provider, model } = await askJev(state, questions);
|
|
586
|
+
const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
|
|
553
587
|
// One invalid Noul makes the whole ordering untrustworthy; never sort a
|
|
554
588
|
// missing answer as a confident zero.
|
|
555
589
|
const scores = candidates.map((_, i) => {
|
|
@@ -597,7 +631,7 @@ server.registerTool("jev_compare", {
|
|
|
597
631
|
"Optionally supply aspects (price, date, method, …) and each gets an independent per-aspect judgment " +
|
|
598
632
|
"in the same single request. Use for source reconciliation, changelog-vs-code drift, or merge sanity checks. " +
|
|
599
633
|
"The request supplies no evidence beyond the two passages, so a same_fact verdict means they agree with each other, not that they are true.",
|
|
600
|
-
inputSchema: {
|
|
634
|
+
inputSchema: strictShape({
|
|
601
635
|
passage_a: z.string().min(1).max(20000).describe("First passage. Rejected above 20,000 characters."),
|
|
602
636
|
passage_b: z.string().min(1).max(20000).describe("Second passage. Rejected above 20,000 characters."),
|
|
603
637
|
aspects: z
|
|
@@ -608,8 +642,8 @@ server.registerTool("jev_compare", {
|
|
|
608
642
|
purpose: z.string().optional().describe("What this comparison is for; helps disambiguate overlap."),
|
|
609
643
|
auto_accept: z.number().min(0).max(1).optional().describe("Minimum top probability for auto. Default 0.85."),
|
|
610
644
|
minimum_margin: z.number().min(0).max(1).optional().describe("Minimum winner-to-runner-up gap for auto. Default 0.5."),
|
|
611
|
-
},
|
|
612
|
-
}, async ({ passage_a, passage_b, aspects: rawAspects, purpose, auto_accept, minimum_margin }) => {
|
|
645
|
+
}),
|
|
646
|
+
}, async ({ passage_a, passage_b, aspects: rawAspects, purpose, auto_accept, minimum_margin }, extra) => {
|
|
613
647
|
const autoAccept = auto_accept ?? 0.85;
|
|
614
648
|
const minMargin = minimum_margin ?? 0.5;
|
|
615
649
|
const aspects = rawAspects ?? [];
|
|
@@ -625,29 +659,12 @@ server.registerTool("jev_compare", {
|
|
|
625
659
|
questions[`aspect_${i}`] = choice(`Judging only the aspect "${aspect}" of the two passages in the state, which relation holds?`, { ...ASPECT_RELATIONS });
|
|
626
660
|
});
|
|
627
661
|
const state = { purpose: purpose ?? null, passage_a: a, passage_b: b, aspects };
|
|
628
|
-
const { answers, usage, provider, model } = await askJev(state, questions);
|
|
662
|
+
const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
|
|
629
663
|
const expected = new Set(Object.keys(COMPARE_RELATIONS));
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
return null;
|
|
633
|
-
const probabilities = answer.probabilities ?? {};
|
|
634
|
-
const keys = Object.keys(probabilities);
|
|
635
|
-
const values = Object.values(probabilities);
|
|
636
|
-
if (keys.length !== expected.size ||
|
|
637
|
-
!keys.every((k) => expected.has(k)) ||
|
|
638
|
-
!values.every((p) => Number.isFinite(p) && p >= 0 && p <= 1) ||
|
|
639
|
-
Math.abs(values.reduce((x, y) => x + y, 0) - 1) > 0.01 ||
|
|
640
|
-
// Choice contract: the chosen option must be the argmax.
|
|
641
|
-
probabilities[answer.choice] < Math.max(...values) - 1e-9)
|
|
642
|
-
return null;
|
|
643
|
-
const rawConfidence = answer.confidence;
|
|
644
|
-
const confidence = typeof rawConfidence === "number" && Number.isFinite(rawConfidence) && rawConfidence >= 0 && rawConfidence <= 1
|
|
645
|
-
? rawConfidence
|
|
646
|
-
: null;
|
|
647
|
-
return { choice: answer.choice, confidence, probabilities };
|
|
648
|
-
};
|
|
664
|
+
// classify-grade validation via the shared validateChoiceAnswer; the
|
|
665
|
+
// overall and aspect questions share the same three relation keys.
|
|
649
666
|
const shape = (raw) => {
|
|
650
|
-
const answer =
|
|
667
|
+
const answer = validateChoiceAnswer(raw, expected);
|
|
651
668
|
if (!answer) {
|
|
652
669
|
return {
|
|
653
670
|
relation: null,
|
|
@@ -739,7 +756,7 @@ server.registerTool("jev_extract", {
|
|
|
739
756
|
"returned verbatim — never model-generated text. Fields with zero regex matches never reach the model " +
|
|
740
757
|
"(not_found); if no field has matches, no API call is made. Ambiguous picks are flagged for review. Use for prices, dates, version numbers, " +
|
|
741
758
|
"IDs, and anything with a recognizable shape; keep documents bounded.",
|
|
742
|
-
inputSchema: {
|
|
759
|
+
inputSchema: strictShape({
|
|
743
760
|
document: z.string().min(1).max(50000).describe("The document to extract from. Rejected above 50,000 characters."),
|
|
744
761
|
fields: z
|
|
745
762
|
.array(z.object({
|
|
@@ -747,15 +764,15 @@ server.registerTool("jev_extract", {
|
|
|
747
764
|
pattern: z.string().min(1).max(500).describe("JavaScript regex source (without delimiters) that matches candidate values. Runs in a sandboxed worker with a hard timeout."),
|
|
748
765
|
flags: z.string().max(8).optional().describe("Regex flags (e.g. 'i'). 'g' is always added; non-letters are dropped."),
|
|
749
766
|
description: z.string().min(1).max(2000).describe("What the field is, so Jev can pick the right candidate among regex matches."),
|
|
750
|
-
}))
|
|
767
|
+
}).strict())
|
|
751
768
|
.min(1)
|
|
752
769
|
.max(MAX_EXTRACT_FIELDS)
|
|
753
770
|
.describe(`Fields to extract. Up to ${MAX_EXTRACT_FIELDS} per call, all judged in one request.`),
|
|
754
771
|
purpose: z.string().optional().describe("What the extraction is for; shared across fields."),
|
|
755
772
|
auto_accept: z.number().min(0).max(1).optional().describe("Minimum top probability for auto. Default 0.85."),
|
|
756
773
|
minimum_margin: z.number().min(0).max(1).optional().describe("Minimum winner-to-runner-up gap for auto. Default 0.5."),
|
|
757
|
-
},
|
|
758
|
-
}, async ({ document, fields: rawFields, purpose, auto_accept, minimum_margin }) => {
|
|
774
|
+
}),
|
|
775
|
+
}, async ({ document, fields: rawFields, purpose, auto_accept, minimum_margin }, extra) => {
|
|
759
776
|
const autoAccept = auto_accept ?? 0.85;
|
|
760
777
|
const minMargin = minimum_margin ?? 0.5;
|
|
761
778
|
const doc = truncate(document, 50000);
|
|
@@ -802,7 +819,7 @@ server.registerTool("jev_extract", {
|
|
|
802
819
|
questions[f.key] = choice(`Which candidate is the correct value of the field "${f.id}" (${f.description}) in the document in the state? Pick the exact substring the document presents as this field's value.`, criteria);
|
|
803
820
|
stateFields.push({ id: f.key, description: f.description, pattern: f.pattern });
|
|
804
821
|
}
|
|
805
|
-
const { answers, usage, provider, model } = stateFields.length > 0 ? await askJev({ purpose: purpose ?? null, document: doc, fields: stateFields }, questions) : { answers: {}, usage: null, provider: "none", model: MODEL };
|
|
822
|
+
const { answers, usage, provider, model } = stateFields.length > 0 ? await askJev({ purpose: purpose ?? null, document: doc, fields: stateFields }, questions, extra.signal) : { answers: {}, usage: null, provider: "none", model: MODEL };
|
|
806
823
|
const results = fields.map((f) => {
|
|
807
824
|
const flags = { candidates_truncated: f.truncated, matches_skipped_too_long: f.tooLong };
|
|
808
825
|
// An incomplete candidate universe (capped or overlong-skipped matches)
|
|
@@ -829,7 +846,7 @@ server.registerTool("jev_extract", {
|
|
|
829
846
|
keys.length === expectedKeys.size &&
|
|
830
847
|
keys.every((k) => expectedKeys.has(k)) &&
|
|
831
848
|
values.every((p) => Number.isFinite(p) && p >= 0 && p <= 1) &&
|
|
832
|
-
Math.abs(values.reduce((x, y) => x + y, 0) - 1) <=
|
|
849
|
+
Math.abs(values.reduce((x, y) => x + y, 0) - 1) <= PROBABILITY_SUM_TOLERANCE &&
|
|
833
850
|
probabilities[answer.choice] >= Math.max(...values) - 1e-9;
|
|
834
851
|
if (!valid) {
|
|
835
852
|
return { id: f.id, value: null, status: "invalid_response", reason: null, candidates_considered: f.candidates.length, ...flags };
|
|
@@ -920,6 +937,13 @@ function reviewQuestions(extraFraming = "") {
|
|
|
920
937
|
}
|
|
921
938
|
// Score contract: a finite score within the 0..2 rubric and confidence finite
|
|
922
939
|
// or null. Malformed responses are never semantic outcomes.
|
|
940
|
+
// Score questions always offer exactly three options (0, 1, 2).
|
|
941
|
+
const SCORE_OPTION_KEYS = ["0", "1", "2"];
|
|
942
|
+
// Score contract: a finite 0..2 score, plus, when a distribution is present
|
|
943
|
+
// at all, an exact three-key distribution over the option scores summing to
|
|
944
|
+
// one whose expected value matches the reported score. A distribution the
|
|
945
|
+
// provider did not report stays null (still valid); a malformed or
|
|
946
|
+
// contradictory one invalidates the answer.
|
|
923
947
|
function validateScoreAnswer(answer) {
|
|
924
948
|
const a = answer;
|
|
925
949
|
if (!a || typeof a.score !== "number" || !Number.isFinite(a.score) || a.score < 0 || a.score > 2)
|
|
@@ -927,7 +951,46 @@ function validateScoreAnswer(answer) {
|
|
|
927
951
|
const confidence = typeof a.confidence === "number" && Number.isFinite(a.confidence) && a.confidence >= 0 && a.confidence <= 1
|
|
928
952
|
? a.confidence
|
|
929
953
|
: null;
|
|
930
|
-
|
|
954
|
+
if (a.probabilities == null)
|
|
955
|
+
return { score: a.score, confidence, probabilities: null };
|
|
956
|
+
if (!isRecord(a.probabilities))
|
|
957
|
+
return null;
|
|
958
|
+
const probabilities = a.probabilities;
|
|
959
|
+
const keys = Object.keys(probabilities);
|
|
960
|
+
const values = Object.values(probabilities);
|
|
961
|
+
const expected = new Set(SCORE_OPTION_KEYS);
|
|
962
|
+
if (keys.length !== expected.size ||
|
|
963
|
+
!keys.every((key) => expected.has(key)) ||
|
|
964
|
+
!values.every((p) => typeof p === "number" && Number.isFinite(p) && p >= 0 && p <= 1) ||
|
|
965
|
+
Math.abs(values.reduce((x, y) => x + y, 0) - 1) > PROBABILITY_SUM_TOLERANCE) {
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
const mean = SCORE_OPTION_KEYS.reduce((sum, key) => sum + Number(key) * probabilities[key], 0);
|
|
969
|
+
if (Math.abs(mean - a.score) > SCORE_MEAN_TOLERANCE)
|
|
970
|
+
return null;
|
|
971
|
+
return { score: a.score, confidence, probabilities };
|
|
972
|
+
}
|
|
973
|
+
// Choice contract: exact candidate keys, finite [0,1] probabilities summing
|
|
974
|
+
// to one within PROBABILITY_SUM_TOLERANCE, and a choice tied for the maximum
|
|
975
|
+
// probability.
|
|
976
|
+
function validateChoiceAnswer(answer, expectedKeys) {
|
|
977
|
+
if (!isRecord(answer) || typeof answer.choice !== "string" || !isRecord(answer.probabilities))
|
|
978
|
+
return null;
|
|
979
|
+
const expected = new Set(expectedKeys);
|
|
980
|
+
// Shape is enforced by the isRecord guards above and the finite [0,1] check below.
|
|
981
|
+
const probabilities = answer.probabilities;
|
|
982
|
+
const keys = Object.keys(probabilities);
|
|
983
|
+
const values = Object.values(probabilities);
|
|
984
|
+
if (!expected.has(answer.choice) || keys.length !== expected.size ||
|
|
985
|
+
!keys.every((key) => expected.has(key)) ||
|
|
986
|
+
!values.every((p) => typeof p === "number" && Number.isFinite(p) && p >= 0 && p <= 1) ||
|
|
987
|
+
Math.abs(values.reduce((a, b) => a + b, 0) - 1) > PROBABILITY_SUM_TOLERANCE ||
|
|
988
|
+
probabilities[answer.choice] < Math.max(...values) - 1e-9)
|
|
989
|
+
return null;
|
|
990
|
+
const confidence = typeof answer.confidence === "number" && Number.isFinite(answer.confidence) && answer.confidence >= 0 && answer.confidence <= 1
|
|
991
|
+
? answer.confidence
|
|
992
|
+
: null;
|
|
993
|
+
return { choice: answer.choice, probabilities, confidence };
|
|
931
994
|
}
|
|
932
995
|
// Noul contract: a finite probability in [0,1].
|
|
933
996
|
function validateNoulAnswer(answer) {
|
|
@@ -946,7 +1009,7 @@ function projectReviewHalf(answers, thresholds, truncated) {
|
|
|
946
1009
|
for (const key of rubrics) {
|
|
947
1010
|
const parsed = validateScoreAnswer(answers[key]);
|
|
948
1011
|
if (!parsed) {
|
|
949
|
-
scores[key] = { score: null, confidence: null, status: "invalid_response" };
|
|
1012
|
+
scores[key] = { score: null, confidence: null, probabilities: null, status: "invalid_response" };
|
|
950
1013
|
invalid = true;
|
|
951
1014
|
}
|
|
952
1015
|
else {
|
|
@@ -964,8 +1027,26 @@ function projectReviewHalf(answers, thresholds, truncated) {
|
|
|
964
1027
|
thresholds: { auto_accept: thresholds.autoAccept, review_at: thresholds.reviewAt, composite_floor: thresholds.compositeFloor },
|
|
965
1028
|
};
|
|
966
1029
|
if (invalid) {
|
|
967
|
-
return {
|
|
1030
|
+
return {
|
|
1031
|
+
...base,
|
|
1032
|
+
action: "escalate",
|
|
1033
|
+
status: "invalid_response",
|
|
1034
|
+
composite: null,
|
|
1035
|
+
reason_codes: ["invalid_response"],
|
|
1036
|
+
limiting_rubrics: [],
|
|
1037
|
+
};
|
|
968
1038
|
}
|
|
1039
|
+
// Per-rubric favorability: the normalized 0..1 contribution each rubric
|
|
1040
|
+
// makes to the composite (test gap and blast radius inverted), used to name
|
|
1041
|
+
// the limiting rubric(s) when the composite is what blocks auto.
|
|
1042
|
+
const favorability = {
|
|
1043
|
+
correctness: valid.correctness.score / 2,
|
|
1044
|
+
spec_match: valid.spec_match.score / 2,
|
|
1045
|
+
test_gap: 1 - valid.test_gap.score / 2,
|
|
1046
|
+
blast_radius: 1 - valid.blast_radius.score / 2,
|
|
1047
|
+
};
|
|
1048
|
+
const favorabilities = rubrics.map((r) => favorability[r]);
|
|
1049
|
+
const minFavorability = Math.min(...favorabilities);
|
|
969
1050
|
const composite = reviewComposite({
|
|
970
1051
|
correctness: valid.correctness.score,
|
|
971
1052
|
specMatch: valid.spec_match.score,
|
|
@@ -976,8 +1057,46 @@ function projectReviewHalf(answers, thresholds, truncated) {
|
|
|
976
1057
|
// number that can satisfy a threshold (a bare zero would, at auto_accept 0).
|
|
977
1058
|
const rubricConfidences = rubrics.map((r) => valid[r].confidence);
|
|
978
1059
|
const minConfidence = rubricConfidences.some((c) => c === null) ? null : Math.min(...rubricConfidences);
|
|
979
|
-
const
|
|
980
|
-
|
|
1060
|
+
const rawAction = reviewAction({ composite, safeToApply: safeToApply, minConfidence, ...thresholds });
|
|
1061
|
+
const action = requireCompleteContext(rawAction, truncated);
|
|
1062
|
+
// Reason codes cover every action path; limiting_rubrics names the rubric(s)
|
|
1063
|
+
// that bound the decision: the null-confidence ones when confidence is
|
|
1064
|
+
// unknown, every rubric tied at the minimum confidence when confidence is
|
|
1065
|
+
// what blocks, and every rubric tied at the minimum favorability when the
|
|
1066
|
+
// composite floor is what blocks (ties always included, never a lone winner).
|
|
1067
|
+
const reasonCodes = [];
|
|
1068
|
+
let limitingRubrics = [];
|
|
1069
|
+
if (minConfidence === null) {
|
|
1070
|
+
reasonCodes.push("unknown_confidence");
|
|
1071
|
+
limitingRubrics = rubrics.filter((r) => valid[r].confidence === null);
|
|
1072
|
+
}
|
|
1073
|
+
else if (minConfidence < thresholds.reviewAt) {
|
|
1074
|
+
reasonCodes.push("confidence_below_review");
|
|
1075
|
+
limitingRubrics = rubrics.filter((r) => valid[r].confidence === minConfidence);
|
|
1076
|
+
}
|
|
1077
|
+
if (safeToApply !== null && safeToApply < thresholds.reviewAt)
|
|
1078
|
+
reasonCodes.push("safe_to_apply_below_review");
|
|
1079
|
+
if (rawAction !== "escalate") {
|
|
1080
|
+
if (safeToApply !== null && safeToApply < thresholds.autoAccept)
|
|
1081
|
+
reasonCodes.push("safe_to_apply_below_auto_accept");
|
|
1082
|
+
if (minConfidence !== null && minConfidence < thresholds.autoAccept) {
|
|
1083
|
+
reasonCodes.push("confidence_below_auto_accept");
|
|
1084
|
+
if (limitingRubrics.length === 0)
|
|
1085
|
+
limitingRubrics = rubrics.filter((r) => valid[r].confidence === minConfidence);
|
|
1086
|
+
}
|
|
1087
|
+
if (composite < thresholds.compositeFloor) {
|
|
1088
|
+
reasonCodes.push("composite_below_floor");
|
|
1089
|
+
// Tolerant equality: complementary scores (0.6 vs 1.4) are
|
|
1090
|
+
// mathematically tied but differ by a float ulp after inversion.
|
|
1091
|
+
if (limitingRubrics.length === 0)
|
|
1092
|
+
limitingRubrics = rubrics.filter((r) => Math.abs(favorability[r] - minFavorability) <= 1e-12);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
if (truncated)
|
|
1096
|
+
reasonCodes.push("incomplete_context");
|
|
1097
|
+
if (action === "auto")
|
|
1098
|
+
reasonCodes.push("accepted");
|
|
1099
|
+
return { ...base, action, composite, reason_codes: reasonCodes, limiting_rubrics: limitingRubrics };
|
|
981
1100
|
}
|
|
982
1101
|
server.registerTool("jev_review", {
|
|
983
1102
|
title: "Review a proposed patch",
|
|
@@ -987,7 +1106,7 @@ server.registerTool("jev_review", {
|
|
|
987
1106
|
"safe_to_apply and min score confidence at auto_accept and the composite at composite_floor; truncated or " +
|
|
988
1107
|
"malformed input never returns auto. Does not apply the patch or run tests. " +
|
|
989
1108
|
"Use jev_gate to also verify completion claims against evidence in the same call.",
|
|
990
|
-
inputSchema: {
|
|
1109
|
+
inputSchema: strictShape({
|
|
991
1110
|
request: z.string().min(1).describe("What the user asked for; this frames the review, it is not proof of anything."),
|
|
992
1111
|
diff: z
|
|
993
1112
|
.string()
|
|
@@ -1012,8 +1131,8 @@ server.registerTool("jev_review", {
|
|
|
1012
1131
|
.max(1)
|
|
1013
1132
|
.optional()
|
|
1014
1133
|
.describe("Weighted composite at or above this is required for auto. Default 0.7."),
|
|
1015
|
-
},
|
|
1016
|
-
}, async ({ request, diff, tests, auto_accept, review_at, composite_floor }) => {
|
|
1134
|
+
}),
|
|
1135
|
+
}, async ({ request, diff, tests, auto_accept, review_at, composite_floor }, extra) => {
|
|
1017
1136
|
const { autoAccept, reviewAt } = resolvePolicyThresholds(auto_accept ?? 0.8, review_at);
|
|
1018
1137
|
const compositeFloor = composite_floor ?? DEFAULT_COMPOSITE_FLOOR;
|
|
1019
1138
|
const truncated = request.length > MAX_REVIEW_DOC_CHARS ||
|
|
@@ -1025,7 +1144,7 @@ server.registerTool("jev_review", {
|
|
|
1025
1144
|
diff: truncate(diff, MAX_REVIEW_DOC_CHARS),
|
|
1026
1145
|
tests: tests ? truncate(tests, MAX_REVIEW_DOC_CHARS) : null,
|
|
1027
1146
|
};
|
|
1028
|
-
const { answers, usage, provider, model } = await askJev(state, reviewQuestions());
|
|
1147
|
+
const { answers, usage, provider, model } = await askJev(state, reviewQuestions(), extra.signal);
|
|
1029
1148
|
return text({
|
|
1030
1149
|
tool: "jev_review",
|
|
1031
1150
|
model,
|
|
@@ -1044,7 +1163,7 @@ server.registerTool("jev_gate", {
|
|
|
1044
1163
|
"evidence. Evidence is capped at 16 items and 200,000 characters in aggregate. " +
|
|
1045
1164
|
"Does not run tests or apply changes. Use jev_review for a patch without claims, jev_verify for " +
|
|
1046
1165
|
"claims without a patch review.",
|
|
1047
|
-
inputSchema: {
|
|
1166
|
+
inputSchema: strictShape({
|
|
1048
1167
|
request: z.string().min(1).describe("What the user asked for; this is not evidence of completion."),
|
|
1049
1168
|
diff: z
|
|
1050
1169
|
.string()
|
|
@@ -1077,8 +1196,8 @@ server.registerTool("jev_gate", {
|
|
|
1077
1196
|
.max(1)
|
|
1078
1197
|
.optional()
|
|
1079
1198
|
.describe("Weighted composite at or above this is required for auto. Default 0.7."),
|
|
1080
|
-
},
|
|
1081
|
-
}, async ({ request, diff, claims, evidence: rawEvidence, tests, auto_accept, review_at, composite_floor }) => {
|
|
1199
|
+
}),
|
|
1200
|
+
}, async ({ request, diff, claims, evidence: rawEvidence, tests, auto_accept, review_at, composite_floor }, extra) => {
|
|
1082
1201
|
const { autoAccept, reviewAt } = resolvePolicyThresholds(auto_accept ?? 0.8, review_at);
|
|
1083
1202
|
const compositeFloor = composite_floor ?? DEFAULT_COMPOSITE_FLOOR;
|
|
1084
1203
|
const evidence = normalizeEvidence(rawEvidence);
|
|
@@ -1124,31 +1243,14 @@ server.registerTool("jev_gate", {
|
|
|
1124
1243
|
"diff and tests belong to the separate patch review. If a claim needs a diff or test log as support, it " +
|
|
1125
1244
|
"must be supplied in evidence." + ANTI_INJECTION, VERIFY_CLAIM_CRITERIA);
|
|
1126
1245
|
});
|
|
1127
|
-
const { answers, usage, provider, model } = await askJev(state, questions);
|
|
1246
|
+
const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
|
|
1128
1247
|
const review = projectReviewHalf(answers, { autoAccept, reviewAt, compositeFloor }, truncated);
|
|
1129
|
-
// classify-grade validation
|
|
1130
|
-
//
|
|
1248
|
+
// classify-grade validation via the shared validateChoiceAnswer: exact
|
|
1249
|
+
// keys, finite [0,1] probabilities summing to one within the shared
|
|
1250
|
+
// tolerance, chosen key is the argmax, confidence finite or null.
|
|
1131
1251
|
const expectedClaimKeys = new Set(Object.keys(VERIFY_CLAIM_CRITERIA));
|
|
1132
|
-
const validateChoice = (answer) => {
|
|
1133
|
-
if (!answer || typeof answer.choice !== "string" || !expectedClaimKeys.has(answer.choice))
|
|
1134
|
-
return null;
|
|
1135
|
-
const probabilities = answer.probabilities ?? {};
|
|
1136
|
-
const keys = Object.keys(probabilities);
|
|
1137
|
-
const values = Object.values(probabilities);
|
|
1138
|
-
if (keys.length !== expectedClaimKeys.size ||
|
|
1139
|
-
!keys.every((k) => expectedClaimKeys.has(k)) ||
|
|
1140
|
-
!values.every((p) => Number.isFinite(p) && p >= 0 && p <= 1) ||
|
|
1141
|
-
Math.abs(values.reduce((a, b) => a + b, 0) - 1) > 0.01 ||
|
|
1142
|
-
probabilities[answer.choice] < Math.max(...values) - 1e-9)
|
|
1143
|
-
return null;
|
|
1144
|
-
const rawConfidence = answer.confidence;
|
|
1145
|
-
const confidence = typeof rawConfidence === "number" && Number.isFinite(rawConfidence) && rawConfidence >= 0 && rawConfidence <= 1
|
|
1146
|
-
? rawConfidence
|
|
1147
|
-
: null;
|
|
1148
|
-
return { choice: answer.choice, confidence, probabilities };
|
|
1149
|
-
};
|
|
1150
1252
|
const results = claims.map((claim, i) => {
|
|
1151
|
-
const answer =
|
|
1253
|
+
const answer = validateChoiceAnswer(answers[`claim_${i}`], expectedClaimKeys);
|
|
1152
1254
|
if (!answer) {
|
|
1153
1255
|
return {
|
|
1154
1256
|
claim,
|
|
@@ -1181,6 +1283,13 @@ server.registerTool("jev_gate", {
|
|
|
1181
1283
|
reasonCodes.push("incomplete_context");
|
|
1182
1284
|
if (review.status === "invalid_response" || verification.summary.invalid_response > 0)
|
|
1183
1285
|
reasonCodes.push("invalid_response");
|
|
1286
|
+
// The review half's specific reason codes (unknown confidence, threshold
|
|
1287
|
+
// and composite misses, safe_to_apply) surface at the top level too, so a
|
|
1288
|
+
// gate consumer never has to open review.reason_codes to learn why.
|
|
1289
|
+
for (const code of review.reason_codes) {
|
|
1290
|
+
if (code !== "invalid_response" && code !== "incomplete_context" && code !== "accepted")
|
|
1291
|
+
reasonCodes.push(code);
|
|
1292
|
+
}
|
|
1184
1293
|
if (review.action === "escalate")
|
|
1185
1294
|
reasonCodes.push("review_escalated");
|
|
1186
1295
|
if (review.action === "review")
|