@emseepea/testing 0.21.7 → 0.23.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 +39 -0
- package/package.json +4 -3
- package/semantic/cli.mjs +2 -148
- package/semantic/evidence.mjs +194 -0
- package/semantic/provider.mjs +3 -11
- package/semantic/selection.mjs +113 -0
- package/semantic/test.d.mts +27 -0
- package/semantic/test.mjs +95 -11
package/README.md
CHANGED
|
@@ -29,6 +29,39 @@ sequence in the listed order and count. The evidence retains all alternatives
|
|
|
29
29
|
and the matched sequence; arguments, meaning, confirmation and feedback still
|
|
30
30
|
need their own assertions.
|
|
31
31
|
|
|
32
|
+
Use `assertToolSelection` when valid tool order or recovery paths can vary, but
|
|
33
|
+
the permitted tools and call counts are bounded. Its tool-selection contract
|
|
34
|
+
lists required, allowed, and explicitly forbidden tools. Any advertised tool
|
|
35
|
+
that is not required or allowed is rejected.
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
assertToolSelection(turn, {
|
|
39
|
+
required: [
|
|
40
|
+
{ name: "list-candidates", minCalls: 1, maxCalls: 1 },
|
|
41
|
+
{ name: "commit-selection", minCalls: 1, maxCalls: 1 },
|
|
42
|
+
],
|
|
43
|
+
allowed: [{ name: "lookup-candidate", maxCalls: 2 }],
|
|
44
|
+
forbidden: [],
|
|
45
|
+
totalCalls: { minCalls: 2, maxCalls: 4 },
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The finite maximums are checked after the turn. They do not stop tool calls or
|
|
50
|
+
truncate evidence.
|
|
51
|
+
|
|
52
|
+
A passing selection assertion proves only that every
|
|
53
|
+
recorded call satisfied the declared tool and count rules. It does not prove
|
|
54
|
+
argument correctness, persisted effects, response meaning, or recovery from an
|
|
55
|
+
error. Keep an exact assertion when order, count, and arguments are
|
|
56
|
+
deterministic, and add the separate assertions your application outcome needs.
|
|
57
|
+
|
|
58
|
+
Each turn also exposes `toolOutcomes`, aligned by trial and call order with
|
|
59
|
+
`toolCalls`. Each outcome is a read-only snapshot of what the model saw. It
|
|
60
|
+
contains only `name`, `arguments`, the model-visible `result`, and `isError`.
|
|
61
|
+
Use it to check an expected error and the later calls that followed the error.
|
|
62
|
+
A later non-error result does not by itself prove recovery or a persisted
|
|
63
|
+
effect.
|
|
64
|
+
|
|
32
65
|
For a successful turn that advertises feedback, use
|
|
33
66
|
`assertToolCallsWithOptionalFeedback`. It requires the exact ordered primary
|
|
34
67
|
calls and arguments, then allows no feedback call or one trailing
|
|
@@ -57,6 +90,12 @@ advertised-tool text, an answer wrapper, or prepared MCP material. Exact tool
|
|
|
57
90
|
assertions come from the provider's native MCP events. Follow-up messages use
|
|
58
91
|
the same conversation.
|
|
59
92
|
|
|
93
|
+
Native conversations have no framework tool-call or native provider-turn count
|
|
94
|
+
limit. Calls must still use advertised MCP tools. The per-send timeout,
|
|
95
|
+
cancellation, output checks, model checks, and isolated test environment remain
|
|
96
|
+
effective. Application assertions decide which calls and results are
|
|
97
|
+
acceptable. Tool-free judge invocations retain their existing limits.
|
|
98
|
+
|
|
60
99
|
Call `await conversation.fresh()` to discard provider transcript history while
|
|
61
100
|
keeping the same running test application and its saved data. The next `send`
|
|
62
101
|
starts a new provider-native session in each trial.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emseepea/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "MCP integration and semantic testing helpers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -43,10 +43,11 @@
|
|
|
43
43
|
"README.md"
|
|
44
44
|
],
|
|
45
45
|
"scripts": {
|
|
46
|
-
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"build": "tsc -p tsconfig.json && tsc -p tsconfig.type-tests.json",
|
|
47
47
|
"prepack": "npm run build",
|
|
48
48
|
"test": "npm run build && npm run test:built",
|
|
49
|
-
"test:built": "node --test test/*.test.mjs"
|
|
49
|
+
"test:built": "node --test test/*.test.mjs",
|
|
50
|
+
"test:llm:built": "emseepea-test eval"
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
53
|
"@emseepea/server": "0.23.0",
|
package/semantic/cli.mjs
CHANGED
|
@@ -1,23 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
3
|
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
5
4
|
import { tmpdir } from "node:os";
|
|
6
5
|
import { dirname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { validRecord } from "./evidence.mjs";
|
|
7
7
|
import { discoverTests } from "./discover.mjs";
|
|
8
8
|
import { modelVersion, providerModel, providerSettings } from "./provider.mjs";
|
|
9
9
|
|
|
10
|
-
const negativeFeedbackObservations = new Set([
|
|
11
|
-
"error",
|
|
12
|
-
"friction",
|
|
13
|
-
"annoyance",
|
|
14
|
-
"unnecessary_difficulty",
|
|
15
|
-
"confusion",
|
|
16
|
-
"repetition",
|
|
17
|
-
"unexpected_bad_result",
|
|
18
|
-
"capability_mismatch",
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
10
|
const paths = [];
|
|
22
11
|
let provider = process.env.EMSEEPEA_EVAL_PROVIDER ?? "claude-local";
|
|
23
12
|
let output = "artifacts/llm-eval/evidence.json";
|
|
@@ -101,7 +90,7 @@ try {
|
|
|
101
90
|
for (const file of files) {
|
|
102
91
|
const displayFile = relative(process.cwd(), file);
|
|
103
92
|
const cases = Object.values(evidence.cases).filter((record) => record.file === displayFile);
|
|
104
|
-
if (!cases.length || cases.some((record) => !validRecord(record, evidence
|
|
93
|
+
if (!cases.length || cases.some((record) => !validRecord(record, evidence))) {
|
|
105
94
|
evidence.errors.push(`Missing or failed qualification: ${displayFile}`);
|
|
106
95
|
}
|
|
107
96
|
}
|
|
@@ -114,138 +103,3 @@ try {
|
|
|
114
103
|
}
|
|
115
104
|
if (evidence.status !== "passed") process.exitCode = 1;
|
|
116
105
|
console.log(`Semantic checks ${evidence.status}; evidence: ${output}`);
|
|
117
|
-
|
|
118
|
-
function validRecord(record, authoritative, smoke) {
|
|
119
|
-
const isHash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
120
|
-
if (record.status !== "passed" || record.authoritative !== authoritative || record.smoke !== smoke
|
|
121
|
-
|| record.provider !== evidence.provider || record.configuredModel !== evidence.configuredModel
|
|
122
|
-
|| record.modelEvidence !== evidence.modelEvidence
|
|
123
|
-
|| JSON.stringify(record.settings) !== JSON.stringify(evidence.settings)
|
|
124
|
-
|| record.mode !== "conversation" || record.answerTrials?.length !== 3
|
|
125
|
-
|| !Number.isInteger(record.judgeVerdicts?.length) || record.judgeVerdicts.length < 9
|
|
126
|
-
|| record.judgeVerdicts.length % 3 !== 0
|
|
127
|
-
|| !record.judgeVerdicts.every((judgment) => isHash(judgment.expectationSha256)
|
|
128
|
-
&& isHash(judgment.requestSha256) && isHash(judgment.responseSha256)
|
|
129
|
-
&& typeof judgment.expectedMeaning === "string" && judgment.expectedMeaning.length > 0
|
|
130
|
-
&& judgment.verdict?.pass === true && judgment.verdict.score === 1
|
|
131
|
-
&& typeof judgment.verdict.reason === "string" && judgment.verdict.reason.length > 0)) return false;
|
|
132
|
-
return record.answerTrials.every((trial) => Array.isArray(trial.turns) && trial.turns.length > 0
|
|
133
|
-
&& trial.turns.every((turn) => Number.isInteger(turn.advertisedToolCount)
|
|
134
|
-
&& turn.advertisedToolCount >= 0
|
|
135
|
-
&& turn.interactionMode === "native-mcp"
|
|
136
|
-
&& turn.answerTurnCount === 1
|
|
137
|
-
&& turn.answerProviderToolCount === turn.toolCallCount
|
|
138
|
-
&& turn.answerProviderTurnCount === turn.toolCallCount + 1
|
|
139
|
-
&& Number.isInteger(turn.toolCallCount) && turn.toolCallCount >= 0 && turn.toolCallCount <= 3
|
|
140
|
-
&& typeof turn.prompt === "string" && turn.prompt.length > 0
|
|
141
|
-
&& typeof turn.response === "string"
|
|
142
|
-
&& isHash(turn.promptSha256) && isHash(turn.answerSha256)
|
|
143
|
-
&& isHash(turn.advertisedToolsSha256) && isHash(turn.selectedCallsSha256)
|
|
144
|
-
&& Array.isArray(turn.toolCalls)
|
|
145
|
-
&& JSON.stringify(turn.selectedTools)
|
|
146
|
-
=== JSON.stringify(turn.toolCalls.map(({ name }) => name))
|
|
147
|
-
&& validToolAssertions(turn, isHash)
|
|
148
|
-
&& validNegativeFeedbackAssertion(turn)
|
|
149
|
-
&& validFeedbackDisclosure(turn, record.judgeVerdicts, trial.trial, isHash)
|
|
150
|
-
&& turn.toolCalls.every((call) => Object.hasOwn(call, "result") && typeof call.isError === "boolean")
|
|
151
|
-
&& Array.isArray(turn.pathEvidence) && turn.pathEvidence.length === turn.toolCallCount
|
|
152
|
-
&& turn.pathEvidence.every(({ method, target, requestSha256, responseSha256 }) =>
|
|
153
|
-
method === "tools/call" && turn.selectedTools.includes(target)
|
|
154
|
-
&& isHash(requestSha256) && isHash(responseSha256))));
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function validToolAssertions(turn, isHash) {
|
|
158
|
-
if (turn.expectedOptionalFeedback === true) {
|
|
159
|
-
if (!Array.isArray(turn.expectedCalls)) return false;
|
|
160
|
-
const expectedHash = createHash("sha256").update(JSON.stringify({
|
|
161
|
-
calls: turn.expectedCalls,
|
|
162
|
-
optionalFeedback: true,
|
|
163
|
-
})).digest("hex");
|
|
164
|
-
const calls = turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args }));
|
|
165
|
-
const primaryCalls = calls.slice(0, turn.expectedCalls?.length);
|
|
166
|
-
const trailingCalls = calls.slice(turn.expectedCalls?.length);
|
|
167
|
-
return expectedHash === turn.expectedSelectionSha256
|
|
168
|
-
&& JSON.stringify(primaryCalls) === JSON.stringify(turn.expectedCalls)
|
|
169
|
-
&& (trailingCalls.length === 0
|
|
170
|
-
|| (trailingCalls.length === 1 && trailingCalls[0].name === "submit-feedback"));
|
|
171
|
-
}
|
|
172
|
-
if (typeof turn.expectedOptionalTool === "string" && turn.expectedOptionalTool.trim()) {
|
|
173
|
-
const expectedHash = createHash("sha256").update(JSON.stringify({
|
|
174
|
-
optionalTool: turn.expectedOptionalTool,
|
|
175
|
-
})).digest("hex");
|
|
176
|
-
return expectedHash === turn.expectedSelectionSha256
|
|
177
|
-
&& (turn.toolCalls.length === 0
|
|
178
|
-
|| (turn.toolCalls.length === 1 && turn.toolCalls[0].name === turn.expectedOptionalTool));
|
|
179
|
-
}
|
|
180
|
-
if (JSON.stringify(turn.selectedTools) !== JSON.stringify(turn.expectedTools)) return false;
|
|
181
|
-
if (turn.expectedToolAlternatives !== undefined) {
|
|
182
|
-
const alternatives = turn.expectedToolAlternatives;
|
|
183
|
-
if (!Array.isArray(alternatives) || alternatives.length < 1 || alternatives.length > 8
|
|
184
|
-
|| !alternatives.every((names) => Array.isArray(names)
|
|
185
|
-
&& names.every((name) => typeof name === "string" && name.trim()))
|
|
186
|
-
|| !alternatives.some((names) => JSON.stringify(names) === JSON.stringify(turn.expectedTools))) {
|
|
187
|
-
return false;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
if (isHash(turn.expectedCallsSha256)) {
|
|
191
|
-
return JSON.stringify(turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))
|
|
192
|
-
=== JSON.stringify(turn.expectedCalls);
|
|
193
|
-
}
|
|
194
|
-
if (!isHash(turn.expectedSelectionSha256)) return false;
|
|
195
|
-
const expectedHash = createHash("sha256").update(JSON.stringify({
|
|
196
|
-
tools: turn.expectedTools,
|
|
197
|
-
arguments: turn.expectedArguments,
|
|
198
|
-
feedback: turn.expectedFeedback,
|
|
199
|
-
...(turn.expectedToolAlternatives === undefined
|
|
200
|
-
? {} : { alternatives: turn.expectedToolAlternatives }),
|
|
201
|
-
})).digest("hex");
|
|
202
|
-
if (expectedHash !== turn.expectedSelectionSha256) return false;
|
|
203
|
-
for (const [name, expected] of Object.entries(turn.expectedArguments ?? {})) {
|
|
204
|
-
const matches = turn.toolCalls.filter((call) => call.name === name);
|
|
205
|
-
if (matches.length !== 1 || JSON.stringify(matches[0].arguments) !== JSON.stringify(expected)) return false;
|
|
206
|
-
}
|
|
207
|
-
if (turn.expectedFeedback) {
|
|
208
|
-
const calls = turn.toolCalls.filter((call) => call.name === "submit-feedback");
|
|
209
|
-
const detail = calls[0]?.arguments?.detail;
|
|
210
|
-
if (calls.length !== 1 || !turn.expectedFeedback.observation.includes(calls[0].arguments?.observation)
|
|
211
|
-
|| typeof detail !== "string" || turn.expectedFeedback.detailIncludes.some(
|
|
212
|
-
(value) => !detail.toLowerCase().includes(value.toLowerCase()),
|
|
213
|
-
)) return false;
|
|
214
|
-
}
|
|
215
|
-
return true;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function validNegativeFeedbackAssertion(turn) {
|
|
219
|
-
if (turn.expectedNegativeFeedback !== false) return true;
|
|
220
|
-
const actual = turn.toolCalls.filter((call) => call.name === "submit-feedback"
|
|
221
|
-
&& negativeFeedbackObservations.has(call.arguments?.observation));
|
|
222
|
-
return actual.length === 0 && JSON.stringify(actual) === JSON.stringify(turn.negativeFeedbackCalls);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function validFeedbackDisclosure(turn, judgments, trial, isHash) {
|
|
226
|
-
if (turn.expectedOptionalFeedback !== true) return turn.feedbackDisclosure === undefined;
|
|
227
|
-
const feedback = turn.toolCalls[turn.expectedCalls.length];
|
|
228
|
-
if (!feedback) return turn.feedbackDisclosure === undefined;
|
|
229
|
-
if (feedback.isError || typeof feedback.arguments?.observation !== "string"
|
|
230
|
-
|| !feedback.arguments.observation.trim() || typeof feedback.arguments.detail !== "string"
|
|
231
|
-
|| !feedback.arguments.detail.trim()) return false;
|
|
232
|
-
const expectedMeaning = feedbackDisclosureExpectation(feedback);
|
|
233
|
-
const expectationSha256 = createHash("sha256").update(expectedMeaning).digest("hex");
|
|
234
|
-
if (!isHash(turn.feedbackDisclosure?.expectationSha256)
|
|
235
|
-
|| JSON.stringify(turn.feedbackDisclosure) !== JSON.stringify({
|
|
236
|
-
observation: feedback.arguments.observation,
|
|
237
|
-
detail: feedback.arguments.detail,
|
|
238
|
-
expectationSha256,
|
|
239
|
-
})) return false;
|
|
240
|
-
const matching = judgments.filter((judgment) => judgment.trial === trial
|
|
241
|
-
&& judgment.turn === turn.turn && judgment.expectationSha256 === expectationSha256);
|
|
242
|
-
return matching.length === 3 && matching.every((judgment) =>
|
|
243
|
-
judgment.expectedMeaning === expectedMeaning && judgment.verdict?.pass === true);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function feedbackDisclosureExpectation(call) {
|
|
247
|
-
return "The final assistant response makes it clear that feedback was submitted, recorded, or noted, "
|
|
248
|
-
+ "and communicates the substance "
|
|
249
|
-
+ `of this specific ${call.arguments.observation} observation: ${call.arguments.detail} `
|
|
250
|
-
+ "A concise, faithful summary is enough. It need not repeat every example, field, or phrase.";
|
|
251
|
-
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
canonicalToolNames,
|
|
4
|
+
normalizeToolSelectionContract,
|
|
5
|
+
toolSelectionContractSatisfied,
|
|
6
|
+
} from "./selection.mjs";
|
|
7
|
+
|
|
8
|
+
const negativeFeedbackObservations = new Set([
|
|
9
|
+
"error",
|
|
10
|
+
"friction",
|
|
11
|
+
"annoyance",
|
|
12
|
+
"unnecessary_difficulty",
|
|
13
|
+
"confusion",
|
|
14
|
+
"repetition",
|
|
15
|
+
"unexpected_bad_result",
|
|
16
|
+
"capability_mismatch",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const sha256 = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
20
|
+
|
|
21
|
+
export function validRecord(record, evidence) {
|
|
22
|
+
const { authoritative, smoke } = evidence;
|
|
23
|
+
const isHash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
24
|
+
if (record.status !== "passed" || record.authoritative !== authoritative || record.smoke !== smoke
|
|
25
|
+
|| record.provider !== evidence.provider || record.configuredModel !== evidence.configuredModel
|
|
26
|
+
|| record.modelEvidence !== evidence.modelEvidence
|
|
27
|
+
|| JSON.stringify(record.settings) !== JSON.stringify(evidence.settings)
|
|
28
|
+
|| record.mode !== "conversation" || record.answerTrials?.length !== 3
|
|
29
|
+
|| !Number.isInteger(record.judgeVerdicts?.length) || record.judgeVerdicts.length < 9
|
|
30
|
+
|| record.judgeVerdicts.length % 3 !== 0
|
|
31
|
+
|| !record.judgeVerdicts.every((judgment) => isHash(judgment.expectationSha256)
|
|
32
|
+
&& isHash(judgment.requestSha256) && isHash(judgment.responseSha256)
|
|
33
|
+
&& typeof judgment.expectedMeaning === "string" && judgment.expectedMeaning.length > 0
|
|
34
|
+
&& judgment.verdict?.pass === true && judgment.verdict.score === 1
|
|
35
|
+
&& typeof judgment.verdict.reason === "string" && judgment.verdict.reason.length > 0)) return false;
|
|
36
|
+
return record.answerTrials.every((trial) => Array.isArray(trial.turns) && trial.turns.length > 0
|
|
37
|
+
&& trial.turns.every((turn) => Number.isInteger(turn.advertisedToolCount)
|
|
38
|
+
&& turn.advertisedToolCount >= 0
|
|
39
|
+
&& turn.interactionMode === "native-mcp"
|
|
40
|
+
&& turn.answerTurnCount === 1
|
|
41
|
+
&& turn.answerProviderToolCount === turn.toolCallCount
|
|
42
|
+
&& Number.isInteger(turn.answerProviderTurnCount) && turn.answerProviderTurnCount >= 1
|
|
43
|
+
&& Number.isInteger(turn.toolCallCount) && turn.toolCallCount >= 0
|
|
44
|
+
&& typeof turn.prompt === "string" && turn.prompt.length > 0
|
|
45
|
+
&& typeof turn.response === "string"
|
|
46
|
+
&& isHash(turn.promptSha256) && isHash(turn.answerSha256)
|
|
47
|
+
&& isHash(turn.advertisedToolsSha256) && isHash(turn.selectedCallsSha256)
|
|
48
|
+
&& Array.isArray(turn.toolCalls) && turn.toolCalls.length === turn.toolCallCount
|
|
49
|
+
&& turn.selectedCallsSha256 === sha256(turn.toolCalls.map(({ name, arguments: args }) => ({
|
|
50
|
+
name,
|
|
51
|
+
arguments: args,
|
|
52
|
+
})))
|
|
53
|
+
&& JSON.stringify(turn.selectedTools)
|
|
54
|
+
=== JSON.stringify(turn.toolCalls.map(({ name }) => name))
|
|
55
|
+
&& validToolAssertions(turn, isHash)
|
|
56
|
+
&& validNegativeFeedbackAssertion(turn)
|
|
57
|
+
&& validFeedbackDisclosure(turn, record.judgeVerdicts, trial.trial, isHash)
|
|
58
|
+
&& turn.toolCalls.every((call) => Object.hasOwn(call, "result") && typeof call.isError === "boolean")
|
|
59
|
+
&& Array.isArray(turn.pathEvidence) && turn.pathEvidence.length === turn.toolCallCount
|
|
60
|
+
&& turn.pathEvidence.every(({ method, target, requestSha256, responseSha256 }, index) => {
|
|
61
|
+
const call = turn.toolCalls[index];
|
|
62
|
+
return method === "tools/call" && target === call.name
|
|
63
|
+
&& requestSha256 === sha256({ method, name: call.name, arguments: call.arguments })
|
|
64
|
+
&& responseSha256 === sha256(call.result);
|
|
65
|
+
})));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validToolAssertions(turn, isHash) {
|
|
69
|
+
if (turn.expectedOptionalFeedback === true) {
|
|
70
|
+
if (!Array.isArray(turn.expectedCalls)) return false;
|
|
71
|
+
const expectedHash = createHash("sha256").update(JSON.stringify({
|
|
72
|
+
calls: turn.expectedCalls,
|
|
73
|
+
optionalFeedback: true,
|
|
74
|
+
})).digest("hex");
|
|
75
|
+
const calls = turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args }));
|
|
76
|
+
const primaryCalls = calls.slice(0, turn.expectedCalls?.length);
|
|
77
|
+
const trailingCalls = calls.slice(turn.expectedCalls?.length);
|
|
78
|
+
return expectedHash === turn.expectedSelectionSha256
|
|
79
|
+
&& JSON.stringify(primaryCalls) === JSON.stringify(turn.expectedCalls)
|
|
80
|
+
&& (trailingCalls.length === 0
|
|
81
|
+
|| (trailingCalls.length === 1 && trailingCalls[0].name === "submit-feedback"));
|
|
82
|
+
}
|
|
83
|
+
if (typeof turn.expectedOptionalTool === "string" && turn.expectedOptionalTool.trim()) {
|
|
84
|
+
const expectedHash = createHash("sha256").update(JSON.stringify({
|
|
85
|
+
optionalTool: turn.expectedOptionalTool,
|
|
86
|
+
})).digest("hex");
|
|
87
|
+
return expectedHash === turn.expectedSelectionSha256
|
|
88
|
+
&& (turn.toolCalls.length === 0
|
|
89
|
+
|| (turn.toolCalls.length === 1 && turn.toolCalls[0].name === turn.expectedOptionalTool));
|
|
90
|
+
}
|
|
91
|
+
if (turn.expectedToolSelection !== undefined) {
|
|
92
|
+
const advertised = turn.advertisedToolNames;
|
|
93
|
+
if (!Array.isArray(advertised) || advertised.length !== turn.advertisedToolCount
|
|
94
|
+
|| advertised.some((name) => typeof name !== "string" || !name.trim())
|
|
95
|
+
|| JSON.stringify(advertised) !== JSON.stringify(canonicalToolNames(new Set(advertised)))
|
|
96
|
+
|| !isHash(turn.advertisedToolNamesSha256)
|
|
97
|
+
|| turn.advertisedToolNamesSha256 !== createHash("sha256")
|
|
98
|
+
.update(JSON.stringify(advertised)).digest("hex")
|
|
99
|
+
|| !isHash(turn.expectedToolSelectionSha256)
|
|
100
|
+
|| !isHash(turn.toolOutcomesSha256)
|
|
101
|
+
|| turn.toolOutcomesSha256 !== createHash("sha256")
|
|
102
|
+
.update(JSON.stringify(turn.toolCalls)).digest("hex")) return false;
|
|
103
|
+
let normalized;
|
|
104
|
+
try {
|
|
105
|
+
normalized = normalizeToolSelectionContract(turn.expectedToolSelection, advertised);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (JSON.stringify(normalized) !== JSON.stringify(turn.expectedToolSelection)
|
|
110
|
+
|| turn.expectedToolSelectionSha256 !== createHash("sha256")
|
|
111
|
+
.update(JSON.stringify(normalized)).digest("hex")) return false;
|
|
112
|
+
return toolSelectionContractSatisfied(normalized, turn.toolCalls)
|
|
113
|
+
&& validOutcomeAssertions(turn, "expectedOutcomeAssertionsSha256");
|
|
114
|
+
}
|
|
115
|
+
if (JSON.stringify(turn.selectedTools) !== JSON.stringify(turn.expectedTools)) return false;
|
|
116
|
+
if (turn.expectedToolAlternatives !== undefined) {
|
|
117
|
+
const alternatives = turn.expectedToolAlternatives;
|
|
118
|
+
if (!Array.isArray(alternatives) || alternatives.length < 1 || alternatives.length > 8
|
|
119
|
+
|| !alternatives.every((names) => Array.isArray(names)
|
|
120
|
+
&& names.every((name) => typeof name === "string" && name.trim()))
|
|
121
|
+
|| !alternatives.some((names) => JSON.stringify(names) === JSON.stringify(turn.expectedTools))) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (isHash(turn.expectedCallsSha256)) {
|
|
126
|
+
return JSON.stringify(turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))
|
|
127
|
+
=== JSON.stringify(turn.expectedCalls);
|
|
128
|
+
}
|
|
129
|
+
return validOutcomeAssertions(turn, "expectedSelectionSha256", true, isHash);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validOutcomeAssertions(turn, digestField, includeTools = false, isHash = (value) => (
|
|
133
|
+
typeof value === "string" && /^[a-f0-9]{64}$/.test(value)
|
|
134
|
+
)) {
|
|
135
|
+
const hasAssertions = turn.expectedArguments !== undefined || turn.expectedFeedback !== undefined;
|
|
136
|
+
if (!includeTools && !hasAssertions) return turn[digestField] === undefined;
|
|
137
|
+
if (!isHash(turn[digestField])) return false;
|
|
138
|
+
const expectedHash = createHash("sha256").update(JSON.stringify({
|
|
139
|
+
...(includeTools ? { tools: turn.expectedTools } : {}),
|
|
140
|
+
arguments: turn.expectedArguments,
|
|
141
|
+
feedback: turn.expectedFeedback,
|
|
142
|
+
...(!includeTools || turn.expectedToolAlternatives === undefined
|
|
143
|
+
? {} : { alternatives: turn.expectedToolAlternatives }),
|
|
144
|
+
})).digest("hex");
|
|
145
|
+
if (expectedHash !== turn[digestField]) return false;
|
|
146
|
+
for (const [name, expected] of Object.entries(turn.expectedArguments ?? {})) {
|
|
147
|
+
const matches = turn.toolCalls.filter((call) => call.name === name);
|
|
148
|
+
if (matches.length !== 1 || JSON.stringify(matches[0].arguments) !== JSON.stringify(expected)) return false;
|
|
149
|
+
}
|
|
150
|
+
if (turn.expectedFeedback) {
|
|
151
|
+
const calls = turn.toolCalls.filter((call) => call.name === "submit-feedback");
|
|
152
|
+
const detail = calls[0]?.arguments?.detail;
|
|
153
|
+
if (calls.length !== 1 || !turn.expectedFeedback.observation.includes(calls[0].arguments?.observation)
|
|
154
|
+
|| typeof detail !== "string" || turn.expectedFeedback.detailIncludes.some(
|
|
155
|
+
(value) => !detail.toLowerCase().includes(value.toLowerCase()),
|
|
156
|
+
)) return false;
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function validNegativeFeedbackAssertion(turn) {
|
|
162
|
+
if (turn.expectedNegativeFeedback !== false) return true;
|
|
163
|
+
const actual = turn.toolCalls.filter((call) => call.name === "submit-feedback"
|
|
164
|
+
&& negativeFeedbackObservations.has(call.arguments?.observation));
|
|
165
|
+
return actual.length === 0 && JSON.stringify(actual) === JSON.stringify(turn.negativeFeedbackCalls);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function validFeedbackDisclosure(turn, judgments, trial, isHash) {
|
|
169
|
+
if (turn.expectedOptionalFeedback !== true) return turn.feedbackDisclosure === undefined;
|
|
170
|
+
const feedback = turn.toolCalls[turn.expectedCalls.length];
|
|
171
|
+
if (!feedback) return turn.feedbackDisclosure === undefined;
|
|
172
|
+
if (feedback.isError || typeof feedback.arguments?.observation !== "string"
|
|
173
|
+
|| !feedback.arguments.observation.trim() || typeof feedback.arguments.detail !== "string"
|
|
174
|
+
|| !feedback.arguments.detail.trim()) return false;
|
|
175
|
+
const expectedMeaning = feedbackDisclosureExpectation(feedback);
|
|
176
|
+
const expectationSha256 = createHash("sha256").update(expectedMeaning).digest("hex");
|
|
177
|
+
if (!isHash(turn.feedbackDisclosure?.expectationSha256)
|
|
178
|
+
|| JSON.stringify(turn.feedbackDisclosure) !== JSON.stringify({
|
|
179
|
+
observation: feedback.arguments.observation,
|
|
180
|
+
detail: feedback.arguments.detail,
|
|
181
|
+
expectationSha256,
|
|
182
|
+
})) return false;
|
|
183
|
+
const matching = judgments.filter((judgment) => judgment.trial === trial
|
|
184
|
+
&& judgment.turn === turn.turn && judgment.expectationSha256 === expectationSha256);
|
|
185
|
+
return matching.length === 3 && matching.every((judgment) =>
|
|
186
|
+
judgment.expectedMeaning === expectedMeaning && judgment.verdict?.pass === true);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function feedbackDisclosureExpectation(call) {
|
|
190
|
+
return "The final assistant response makes it clear that feedback was submitted, recorded, or noted, "
|
|
191
|
+
+ "and communicates the substance "
|
|
192
|
+
+ `of this specific ${call.arguments.observation} observation: ${call.arguments.detail} `
|
|
193
|
+
+ "A concise, faithful summary is enough. It need not repeat every example, field, or phrase.";
|
|
194
|
+
}
|
package/semantic/provider.mjs
CHANGED
|
@@ -50,7 +50,7 @@ export function providerSettings(provider) {
|
|
|
50
50
|
userConfig: "ignored",
|
|
51
51
|
webSearch: false,
|
|
52
52
|
}
|
|
53
|
-
: { effort: "low", maxTurns: 4, permissionMode: "dontAsk", userSettings: false };
|
|
53
|
+
: { effort: "low", maxTurns: null, judgeMaxTurns: 4, permissionMode: "dontAsk", userSettings: false };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export function parseClaudeEvents(stdout, processExitCode = 0) {
|
|
@@ -115,11 +115,6 @@ export function parseNativeClaudeEvents(stdout, advertisedTools, requireInit = f
|
|
|
115
115
|
}
|
|
116
116
|
return { name: publicName, arguments: input };
|
|
117
117
|
});
|
|
118
|
-
if (toolUses.length > 3) {
|
|
119
|
-
throw Object.assign(new Error("Model command used more than three tools"), {
|
|
120
|
-
attemptedToolCalls: calls,
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
118
|
if (init) {
|
|
124
119
|
const available = [...(init.tools ?? [])].sort();
|
|
125
120
|
const expected = [...advertised.keys()].sort();
|
|
@@ -156,9 +151,8 @@ export function parseNativeClaudeEvents(stdout, advertisedTools, requireInit = f
|
|
|
156
151
|
if ((result.permission_denials?.length ?? 0) > 0) {
|
|
157
152
|
throw new Error("Model command attempted a forbidden action");
|
|
158
153
|
}
|
|
159
|
-
if (!Number.isInteger(result.num_turns)
|
|
160
|
-
|
|
161
|
-
throw new Error("Model command used an unexpected number of turns");
|
|
154
|
+
if (!Number.isInteger(result.num_turns) || result.num_turns < 1) {
|
|
155
|
+
throw new Error("Model command returned an invalid turn count");
|
|
162
156
|
}
|
|
163
157
|
const usage = result.modelUsage?.[claudeModel];
|
|
164
158
|
if (usage?.canonicalModel !== claudeModel || usage.provider !== "firstParty") {
|
|
@@ -251,7 +245,6 @@ export function conversationInvocation(provider, directory, url, tools, authToke
|
|
|
251
245
|
"--verbose",
|
|
252
246
|
"--model", claudeModel,
|
|
253
247
|
"--effort", "low",
|
|
254
|
-
"--max-turns", "4",
|
|
255
248
|
"--strict-mcp-config",
|
|
256
249
|
...(config ? ["--mcp-config", JSON.stringify(config)] : []),
|
|
257
250
|
"--disable-slash-commands",
|
|
@@ -444,7 +437,6 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
|
|
|
444
437
|
const forbidden = completed.filter((item) => !["agent_message", "reasoning", "mcp_tool_call"].includes(item?.type));
|
|
445
438
|
if (forbidden.length) throw new Error("Model command used a forbidden tool");
|
|
446
439
|
const toolItems = completed.filter(({ type }) => type === "mcp_tool_call");
|
|
447
|
-
if (toolItems.length > 3) throw new Error("Model command used more than three tools");
|
|
448
440
|
const calls = toolItems.map((item) => {
|
|
449
441
|
if (item.server !== mcpServerName || !advertised.has(item.tool)
|
|
450
442
|
|| !item.arguments || typeof item.arguments !== "object" || Array.isArray(item.arguments)) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const contractKeys = ["allowed", "forbidden", "required", "totalCalls"];
|
|
2
|
+
const contractKeysWithoutForbidden = ["allowed", "required", "totalCalls"];
|
|
3
|
+
const requiredKeys = ["maxCalls", "minCalls", "name"];
|
|
4
|
+
const allowedKeys = ["maxCalls", "name"];
|
|
5
|
+
const totalKeys = ["maxCalls", "minCalls"];
|
|
6
|
+
|
|
7
|
+
function exactKeys(value, keys) {
|
|
8
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
9
|
+
&& JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function name(value) {
|
|
13
|
+
return typeof value === "string" && value.trim() === value && value.length > 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function count(value, minimum) {
|
|
17
|
+
return Number.isSafeInteger(value) && value >= minimum;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function freeze(value) {
|
|
21
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
22
|
+
for (const nested of Object.values(value)) freeze(nested);
|
|
23
|
+
Object.freeze(value);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function compareToolNames(left, right) {
|
|
29
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function canonicalToolNames(names) {
|
|
33
|
+
return [...names].sort(compareToolNames);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeToolSelectionContract(contract, advertisedToolNames) {
|
|
37
|
+
if (!(exactKeys(contract, contractKeys) || exactKeys(contract, contractKeysWithoutForbidden))
|
|
38
|
+
|| !Array.isArray(contract.required) || !Array.isArray(contract.allowed)
|
|
39
|
+
|| (contract.forbidden !== undefined && !Array.isArray(contract.forbidden))
|
|
40
|
+
|| !exactKeys(contract.totalCalls, totalKeys)) {
|
|
41
|
+
throw new TypeError("Tool-selection contract has an invalid shape");
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(advertisedToolNames) || advertisedToolNames.some((toolName) => !name(toolName))
|
|
44
|
+
|| new Set(advertisedToolNames).size !== advertisedToolNames.length) {
|
|
45
|
+
throw new TypeError("Advertised tool names must be unique non-empty strings");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const required = contract.required.map((entry) => {
|
|
49
|
+
if (!exactKeys(entry, requiredKeys) || !name(entry.name)
|
|
50
|
+
|| !count(entry.minCalls, 1) || !count(entry.maxCalls, entry.minCalls)) {
|
|
51
|
+
throw new TypeError("Required tools need a name and valid minimum and maximum call counts");
|
|
52
|
+
}
|
|
53
|
+
return { name: entry.name, minCalls: entry.minCalls, maxCalls: entry.maxCalls };
|
|
54
|
+
}).sort((left, right) => compareToolNames(left.name, right.name));
|
|
55
|
+
const allowed = contract.allowed.map((entry) => {
|
|
56
|
+
if (!exactKeys(entry, allowedKeys) || !name(entry.name) || !count(entry.maxCalls, 1)) {
|
|
57
|
+
throw new TypeError("Allowed tools need a name and a positive maximum call count");
|
|
58
|
+
}
|
|
59
|
+
return { name: entry.name, maxCalls: entry.maxCalls };
|
|
60
|
+
}).sort((left, right) => compareToolNames(left.name, right.name));
|
|
61
|
+
const forbidden = canonicalToolNames(contract.forbidden ?? []);
|
|
62
|
+
if (forbidden.some((toolName) => !name(toolName))) {
|
|
63
|
+
throw new TypeError("Forbidden tools must be non-empty strings");
|
|
64
|
+
}
|
|
65
|
+
const named = [...required.map(({ name: toolName }) => toolName),
|
|
66
|
+
...allowed.map(({ name: toolName }) => toolName), ...forbidden];
|
|
67
|
+
if (new Set(named).size !== named.length) {
|
|
68
|
+
throw new TypeError("Required, allowed, and forbidden tools must be disjoint and unique");
|
|
69
|
+
}
|
|
70
|
+
const advertised = new Set(advertisedToolNames);
|
|
71
|
+
if (named.some((toolName) => !advertised.has(toolName))) {
|
|
72
|
+
throw new TypeError("Every tool-selection contract name must be advertised");
|
|
73
|
+
}
|
|
74
|
+
if (required.length + allowed.length === 0) {
|
|
75
|
+
throw new TypeError("Tool-selection contract must permit at least one tool");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { minCalls, maxCalls } = contract.totalCalls;
|
|
79
|
+
if (!count(minCalls, 0) || !count(maxCalls, minCalls)) {
|
|
80
|
+
throw new TypeError("Total call counts must be finite safe integers in ascending order");
|
|
81
|
+
}
|
|
82
|
+
const requiredMinimum = required.reduce((total, entry) => total + entry.minCalls, 0);
|
|
83
|
+
const permittedMaximum = [...required, ...allowed].reduce((total, entry) => total + entry.maxCalls, 0);
|
|
84
|
+
if (!Number.isSafeInteger(requiredMinimum) || !Number.isSafeInteger(permittedMaximum)
|
|
85
|
+
|| minCalls < requiredMinimum || maxCalls > permittedMaximum) {
|
|
86
|
+
throw new TypeError("Total call counts must fit the declared per-tool cardinalities");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return freeze({
|
|
90
|
+
required,
|
|
91
|
+
allowed,
|
|
92
|
+
forbidden,
|
|
93
|
+
totalCalls: { minCalls, maxCalls },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function toolSelectionContractSatisfied(contract, calls) {
|
|
98
|
+
if (!Array.isArray(calls) || calls.some((call) => !call || !name(call.name))) return false;
|
|
99
|
+
if (calls.length < contract.totalCalls.minCalls || calls.length > contract.totalCalls.maxCalls) return false;
|
|
100
|
+
const rules = new Map([
|
|
101
|
+
...contract.required.map((entry) => [entry.name, entry]),
|
|
102
|
+
...contract.allowed.map((entry) => [entry.name, { ...entry, minCalls: 0 }]),
|
|
103
|
+
]);
|
|
104
|
+
const counts = new Map();
|
|
105
|
+
for (const { name: toolName } of calls) {
|
|
106
|
+
if (!rules.has(toolName)) return false;
|
|
107
|
+
counts.set(toolName, (counts.get(toolName) ?? 0) + 1);
|
|
108
|
+
}
|
|
109
|
+
return [...rules].every(([toolName, rule]) => {
|
|
110
|
+
const actual = counts.get(toolName) ?? 0;
|
|
111
|
+
return actual >= rule.minCalls && actual <= rule.maxCalls;
|
|
112
|
+
});
|
|
113
|
+
}
|
package/semantic/test.d.mts
CHANGED
|
@@ -5,6 +5,30 @@ export interface ToolCall {
|
|
|
5
5
|
arguments: Record<string, unknown>;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
export interface ToolOutcome {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
11
|
+
readonly result: unknown;
|
|
12
|
+
readonly isError: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ToolSelectionContract {
|
|
16
|
+
readonly required: readonly {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly minCalls: number;
|
|
19
|
+
readonly maxCalls: number;
|
|
20
|
+
}[];
|
|
21
|
+
readonly allowed: readonly {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly maxCalls: number;
|
|
24
|
+
}[];
|
|
25
|
+
readonly forbidden?: readonly string[];
|
|
26
|
+
readonly totalCalls: {
|
|
27
|
+
readonly minCalls: number;
|
|
28
|
+
readonly maxCalls: number;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
8
32
|
export interface ConversationOptions {
|
|
9
33
|
server: URL;
|
|
10
34
|
/** Real application context supplied in production. Never use this as test guidance. */
|
|
@@ -18,6 +42,7 @@ export interface ConversationOptions {
|
|
|
18
42
|
export interface ConversationTurn {
|
|
19
43
|
readonly responses: readonly string[];
|
|
20
44
|
readonly toolCalls: readonly (readonly ToolCall[])[];
|
|
45
|
+
readonly toolOutcomes: readonly (readonly ToolOutcome[])[];
|
|
21
46
|
readonly elicitations: readonly (readonly ElicitationEvidence[])[];
|
|
22
47
|
}
|
|
23
48
|
|
|
@@ -61,6 +86,8 @@ export function assertToolNames(
|
|
|
61
86
|
turn: ConversationTurn,
|
|
62
87
|
expected: readonly string[] | { readonly oneOf: readonly (readonly string[])[] },
|
|
63
88
|
): void;
|
|
89
|
+
/** Requires completed calls to satisfy an independently authored closed-world selection contract. */
|
|
90
|
+
export function assertToolSelection(turn: ConversationTurn, contract: ToolSelectionContract): void;
|
|
64
91
|
export function assertToolArguments(
|
|
65
92
|
turn: ConversationTurn,
|
|
66
93
|
name: string,
|
package/semantic/test.mjs
CHANGED
|
@@ -11,11 +11,28 @@ import {
|
|
|
11
11
|
stopSemanticServer,
|
|
12
12
|
} from "./material.mjs";
|
|
13
13
|
import { parseJudgeVerdict, providerModel, providerSettings, runModel, startModelConversation } from "./provider.mjs";
|
|
14
|
+
import {
|
|
15
|
+
canonicalToolNames,
|
|
16
|
+
normalizeToolSelectionContract,
|
|
17
|
+
toolSelectionContractSatisfied,
|
|
18
|
+
} from "./selection.mjs";
|
|
14
19
|
|
|
15
20
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
16
21
|
const names = new Set();
|
|
17
22
|
const privateTurn = Symbol("emseepea-semantic-turn");
|
|
18
23
|
|
|
24
|
+
function immutableSnapshot(value) {
|
|
25
|
+
const snapshot = structuredClone(value);
|
|
26
|
+
const freeze = (entry) => {
|
|
27
|
+
if (entry && typeof entry === "object" && !Object.isFrozen(entry)) {
|
|
28
|
+
for (const nested of Object.values(entry)) freeze(nested);
|
|
29
|
+
Object.freeze(entry);
|
|
30
|
+
}
|
|
31
|
+
return entry;
|
|
32
|
+
};
|
|
33
|
+
return freeze(snapshot);
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
export async function createConversation(testContext, options) {
|
|
20
37
|
const specification = validateConversationOptions(options);
|
|
21
38
|
if (!testContext || typeof testContext.name !== "string" || typeof testContext.after !== "function") {
|
|
@@ -104,13 +121,14 @@ export async function createConversation(testContext, options) {
|
|
|
104
121
|
);
|
|
105
122
|
const answer = await trial.model.send(prompt, options);
|
|
106
123
|
const calls = answer.calls;
|
|
124
|
+
const recordedToolCalls = calls.map((call, index) => ({
|
|
125
|
+
...call,
|
|
126
|
+
result: answer.toolResults[index].content,
|
|
127
|
+
isError: answer.toolResults[index].isError,
|
|
128
|
+
}));
|
|
107
129
|
trial.history.push({
|
|
108
130
|
user: prompt,
|
|
109
|
-
toolCalls:
|
|
110
|
-
...call,
|
|
111
|
-
result: answer.toolResults[index].content,
|
|
112
|
-
isError: answer.toolResults[index].isError,
|
|
113
|
-
})),
|
|
131
|
+
toolCalls: recordedToolCalls,
|
|
114
132
|
assistant: answer.answer,
|
|
115
133
|
});
|
|
116
134
|
const record = {
|
|
@@ -120,11 +138,8 @@ export async function createConversation(testContext, options) {
|
|
|
120
138
|
prompt,
|
|
121
139
|
response: answer.answer,
|
|
122
140
|
elicitations: answer.elicitations,
|
|
123
|
-
toolCalls:
|
|
124
|
-
|
|
125
|
-
result: answer.toolResults[index].content,
|
|
126
|
-
isError: answer.toolResults[index].isError,
|
|
127
|
-
})),
|
|
141
|
+
toolCalls: recordedToolCalls,
|
|
142
|
+
toolOutcomesSha256: hash(JSON.stringify(recordedToolCalls)),
|
|
128
143
|
promptSha256: hash(prompt),
|
|
129
144
|
answerSha256: hash(answer.answer),
|
|
130
145
|
answerModels: answer.models,
|
|
@@ -148,6 +163,7 @@ export async function createConversation(testContext, options) {
|
|
|
148
163
|
calls,
|
|
149
164
|
prompt,
|
|
150
165
|
record,
|
|
166
|
+
tools: trial.tools,
|
|
151
167
|
state,
|
|
152
168
|
evidence,
|
|
153
169
|
provider,
|
|
@@ -176,6 +192,9 @@ export async function createConversation(testContext, options) {
|
|
|
176
192
|
return Object.freeze({
|
|
177
193
|
responses: Object.freeze(trials.map(({ answer }) => answer)),
|
|
178
194
|
toolCalls: Object.freeze(trials.map(({ calls }) => Object.freeze(calls))),
|
|
195
|
+
toolOutcomes: Object.freeze(trials.map(({ record }) => immutableSnapshot(record.toolCalls.map(
|
|
196
|
+
({ name, arguments: args, result, isError }) => ({ name, arguments: args, result, isError }),
|
|
197
|
+
)))),
|
|
179
198
|
elicitations: Object.freeze(trials.map(({ record }) => Object.freeze(record.elicitations))),
|
|
180
199
|
[privateTurn]: trials,
|
|
181
200
|
});
|
|
@@ -194,6 +213,7 @@ export async function createConversation(testContext, options) {
|
|
|
194
213
|
|
|
195
214
|
export function assertToolCalls(turn, expected) {
|
|
196
215
|
const trials = turnTrials(turn);
|
|
216
|
+
ensureNoDeclarativeSelection(trials);
|
|
197
217
|
validateExpectedCalls(expected);
|
|
198
218
|
for (const trial of trials) {
|
|
199
219
|
trial.record.expectedTools = expected.map(({ name }) => name);
|
|
@@ -210,6 +230,7 @@ export function assertToolCalls(turn, expected) {
|
|
|
210
230
|
|
|
211
231
|
export async function assertToolCallsWithOptionalFeedback(turn, expected) {
|
|
212
232
|
const trials = turnTrials(turn);
|
|
233
|
+
ensureNoDeclarativeSelection(trials);
|
|
213
234
|
validateExpectedCalls(expected);
|
|
214
235
|
const judgments = [];
|
|
215
236
|
for (let trialIndex = 0; trialIndex < trials.length; trialIndex += 1) {
|
|
@@ -266,6 +287,7 @@ export function assertNoToolCalls(turn) {
|
|
|
266
287
|
|
|
267
288
|
export function assertOptionalToolCall(turn, name) {
|
|
268
289
|
const trials = turnTrials(turn);
|
|
290
|
+
ensureNoDeclarativeSelection(trials);
|
|
269
291
|
if (typeof name !== "string" || !name.trim()) {
|
|
270
292
|
throw new Error("Optional tool-call expectation needs a tool name");
|
|
271
293
|
}
|
|
@@ -281,6 +303,7 @@ export function assertOptionalToolCall(turn, name) {
|
|
|
281
303
|
|
|
282
304
|
export function assertToolNames(turn, expected) {
|
|
283
305
|
const trials = turnTrials(turn);
|
|
306
|
+
ensureNoDeclarativeSelection(trials);
|
|
284
307
|
const validNames = (names) => Array.isArray(names)
|
|
285
308
|
&& names.every((name) => typeof name === "string" && name.trim());
|
|
286
309
|
const alternatives = Array.isArray(expected) ? undefined : expected?.oneOf;
|
|
@@ -309,6 +332,40 @@ export function assertToolNames(turn, expected) {
|
|
|
309
332
|
}
|
|
310
333
|
}
|
|
311
334
|
|
|
335
|
+
export function assertToolSelection(turn, contract) {
|
|
336
|
+
const trials = turnTrials(turn);
|
|
337
|
+
ensureNoExactSelection(trials);
|
|
338
|
+
let candidates;
|
|
339
|
+
try {
|
|
340
|
+
candidates = trials.map((trial) => {
|
|
341
|
+
const advertisedToolNames = canonicalToolNames(trial.tools.map(({ name }) => name));
|
|
342
|
+
return { advertisedToolNames, normalized: normalizeToolSelectionContract(contract, advertisedToolNames) };
|
|
343
|
+
});
|
|
344
|
+
if (candidates.some(({ normalized }) => JSON.stringify(normalized)
|
|
345
|
+
!== JSON.stringify(candidates[0].normalized))) {
|
|
346
|
+
throw new Error("Tool-selection contract must normalize identically for every trial");
|
|
347
|
+
}
|
|
348
|
+
} catch (error) {
|
|
349
|
+
failAssertion(trials, "tool-selection contract assertion");
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
const normalized = candidates[0].normalized;
|
|
353
|
+
trials.forEach((trial, index) => {
|
|
354
|
+
const { advertisedToolNames } = candidates[index];
|
|
355
|
+
trial.record.expectedToolSelection = structuredClone(normalized);
|
|
356
|
+
trial.record.expectedToolSelectionSha256 = hash(JSON.stringify(normalized));
|
|
357
|
+
trial.record.advertisedToolNames = advertisedToolNames;
|
|
358
|
+
trial.record.advertisedToolNamesSha256 = hash(JSON.stringify(advertisedToolNames));
|
|
359
|
+
if (trial.record.expectedArguments !== undefined || trial.record.expectedFeedback !== undefined) {
|
|
360
|
+
recordFlexibleExpectation(trial.record);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
if (!trials.every((trial) => toolSelectionContractSatisfied(normalized, trial.calls))) {
|
|
364
|
+
failAssertion(trials, "tool-selection contract assertion");
|
|
365
|
+
throw new Error("Tool calls did not satisfy the declared selection contract");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
312
369
|
export function assertToolArguments(turn, name, expected) {
|
|
313
370
|
const trials = turnTrials(turn);
|
|
314
371
|
if (typeof name !== "string" || !name.trim() || !expected || typeof expected !== "object"
|
|
@@ -496,7 +553,6 @@ function safeModelFailure(error) {
|
|
|
496
553
|
"Model command did not resume the required session",
|
|
497
554
|
"Model command reported a failed MCP tool call",
|
|
498
555
|
"Model command exposed a configured secret",
|
|
499
|
-
"Model command used more than three tools",
|
|
500
556
|
"Model conversation already has a pending turn",
|
|
501
557
|
"Model conversation could not start",
|
|
502
558
|
"Model conversation is closed",
|
|
@@ -537,6 +593,7 @@ async function closeConversation(state, evidence, output) {
|
|
|
537
593
|
const complete = !state.failed && state.meaningAssertions > 0 && evidence.answerTrials.length === 3
|
|
538
594
|
&& evidence.answerTrials.every(({ turns }) => turns.length > 0
|
|
539
595
|
&& turns.every((turn) => Array.isArray(turn.expectedTools)
|
|
596
|
+
|| turn.expectedToolSelection !== undefined
|
|
540
597
|
|| turn.expectedOptionalFeedback === true
|
|
541
598
|
|| typeof turn.expectedOptionalTool === "string"));
|
|
542
599
|
if (complete) {
|
|
@@ -583,7 +640,34 @@ function failAssertion(trials, phase) {
|
|
|
583
640
|
trials[0].evidence.failedPhase = phase;
|
|
584
641
|
}
|
|
585
642
|
|
|
643
|
+
function ensureNoDeclarativeSelection(trials) {
|
|
644
|
+
if (trials.some(({ record }) => record.expectedToolSelection !== undefined)) {
|
|
645
|
+
failAssertion(trials, "tool-selection contract assertion");
|
|
646
|
+
throw new Error("Cannot combine exact and declarative tool-selection assertions");
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function ensureNoExactSelection(trials) {
|
|
651
|
+
const hasExactSelection = ({ record }) => record.expectedCalls !== undefined
|
|
652
|
+
|| record.expectedTools !== undefined
|
|
653
|
+
|| record.expectedOptionalTool !== undefined
|
|
654
|
+
|| record.expectedOptionalFeedback !== undefined
|
|
655
|
+
|| record.expectedToolAlternatives !== undefined;
|
|
656
|
+
if (trials.some(hasExactSelection)) {
|
|
657
|
+
failAssertion(trials, "tool-selection contract assertion");
|
|
658
|
+
throw new Error("Cannot combine exact and declarative tool-selection assertions");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
586
662
|
function recordFlexibleExpectation(record) {
|
|
663
|
+
if (record.expectedToolSelection !== undefined) {
|
|
664
|
+
delete record.expectedSelectionSha256;
|
|
665
|
+
record.expectedOutcomeAssertionsSha256 = hash(JSON.stringify({
|
|
666
|
+
arguments: record.expectedArguments,
|
|
667
|
+
feedback: record.expectedFeedback,
|
|
668
|
+
}));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
587
671
|
record.expectedSelectionSha256 = hash(JSON.stringify({
|
|
588
672
|
tools: record.expectedTools,
|
|
589
673
|
arguments: record.expectedArguments,
|