@emseepea/testing 0.21.7 → 0.22.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 +6 -0
- package/package.json +3 -2
- package/semantic/cli.mjs +2 -148
- package/semantic/evidence.mjs +148 -0
- package/semantic/provider.mjs +3 -11
- package/semantic/test.mjs +0 -1
package/README.md
CHANGED
|
@@ -57,6 +57,12 @@ advertised-tool text, an answer wrapper, or prepared MCP material. Exact tool
|
|
|
57
57
|
assertions come from the provider's native MCP events. Follow-up messages use
|
|
58
58
|
the same conversation.
|
|
59
59
|
|
|
60
|
+
Native conversations have no framework tool-call or native provider-turn count
|
|
61
|
+
limit. Calls must still use advertised MCP tools. The per-send timeout,
|
|
62
|
+
cancellation, output checks, model checks, and isolated test environment remain
|
|
63
|
+
effective. Exact application assertions decide which calls and results are
|
|
64
|
+
acceptable. Tool-free judge invocations retain their existing limits.
|
|
65
|
+
|
|
60
66
|
Call `await conversation.fresh()` to discard provider transcript history while
|
|
61
67
|
keeping the same running test application and its saved data. The next `send`
|
|
62
68
|
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.22.0",
|
|
4
4
|
"description": "MCP integration and semantic testing helpers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"build": "tsc -p tsconfig.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,148 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const negativeFeedbackObservations = new Set([
|
|
4
|
+
"error",
|
|
5
|
+
"friction",
|
|
6
|
+
"annoyance",
|
|
7
|
+
"unnecessary_difficulty",
|
|
8
|
+
"confusion",
|
|
9
|
+
"repetition",
|
|
10
|
+
"unexpected_bad_result",
|
|
11
|
+
"capability_mismatch",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export function validRecord(record, evidence) {
|
|
15
|
+
const { authoritative, smoke } = evidence;
|
|
16
|
+
const isHash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
17
|
+
if (record.status !== "passed" || record.authoritative !== authoritative || record.smoke !== smoke
|
|
18
|
+
|| record.provider !== evidence.provider || record.configuredModel !== evidence.configuredModel
|
|
19
|
+
|| record.modelEvidence !== evidence.modelEvidence
|
|
20
|
+
|| JSON.stringify(record.settings) !== JSON.stringify(evidence.settings)
|
|
21
|
+
|| record.mode !== "conversation" || record.answerTrials?.length !== 3
|
|
22
|
+
|| !Number.isInteger(record.judgeVerdicts?.length) || record.judgeVerdicts.length < 9
|
|
23
|
+
|| record.judgeVerdicts.length % 3 !== 0
|
|
24
|
+
|| !record.judgeVerdicts.every((judgment) => isHash(judgment.expectationSha256)
|
|
25
|
+
&& isHash(judgment.requestSha256) && isHash(judgment.responseSha256)
|
|
26
|
+
&& typeof judgment.expectedMeaning === "string" && judgment.expectedMeaning.length > 0
|
|
27
|
+
&& judgment.verdict?.pass === true && judgment.verdict.score === 1
|
|
28
|
+
&& typeof judgment.verdict.reason === "string" && judgment.verdict.reason.length > 0)) return false;
|
|
29
|
+
return record.answerTrials.every((trial) => Array.isArray(trial.turns) && trial.turns.length > 0
|
|
30
|
+
&& trial.turns.every((turn) => Number.isInteger(turn.advertisedToolCount)
|
|
31
|
+
&& turn.advertisedToolCount >= 0
|
|
32
|
+
&& turn.interactionMode === "native-mcp"
|
|
33
|
+
&& turn.answerTurnCount === 1
|
|
34
|
+
&& turn.answerProviderToolCount === turn.toolCallCount
|
|
35
|
+
&& Number.isInteger(turn.answerProviderTurnCount) && turn.answerProviderTurnCount >= 1
|
|
36
|
+
&& Number.isInteger(turn.toolCallCount) && turn.toolCallCount >= 0
|
|
37
|
+
&& typeof turn.prompt === "string" && turn.prompt.length > 0
|
|
38
|
+
&& typeof turn.response === "string"
|
|
39
|
+
&& isHash(turn.promptSha256) && isHash(turn.answerSha256)
|
|
40
|
+
&& isHash(turn.advertisedToolsSha256) && isHash(turn.selectedCallsSha256)
|
|
41
|
+
&& Array.isArray(turn.toolCalls) && turn.toolCalls.length === turn.toolCallCount
|
|
42
|
+
&& JSON.stringify(turn.selectedTools)
|
|
43
|
+
=== JSON.stringify(turn.toolCalls.map(({ name }) => name))
|
|
44
|
+
&& validToolAssertions(turn, isHash)
|
|
45
|
+
&& validNegativeFeedbackAssertion(turn)
|
|
46
|
+
&& validFeedbackDisclosure(turn, record.judgeVerdicts, trial.trial, isHash)
|
|
47
|
+
&& turn.toolCalls.every((call) => Object.hasOwn(call, "result") && typeof call.isError === "boolean")
|
|
48
|
+
&& Array.isArray(turn.pathEvidence) && turn.pathEvidence.length === turn.toolCallCount
|
|
49
|
+
&& turn.pathEvidence.every(({ method, target, requestSha256, responseSha256 }) =>
|
|
50
|
+
method === "tools/call" && turn.selectedTools.includes(target)
|
|
51
|
+
&& isHash(requestSha256) && isHash(responseSha256))));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validToolAssertions(turn, isHash) {
|
|
55
|
+
if (turn.expectedOptionalFeedback === true) {
|
|
56
|
+
if (!Array.isArray(turn.expectedCalls)) return false;
|
|
57
|
+
const expectedHash = createHash("sha256").update(JSON.stringify({
|
|
58
|
+
calls: turn.expectedCalls,
|
|
59
|
+
optionalFeedback: true,
|
|
60
|
+
})).digest("hex");
|
|
61
|
+
const calls = turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args }));
|
|
62
|
+
const primaryCalls = calls.slice(0, turn.expectedCalls?.length);
|
|
63
|
+
const trailingCalls = calls.slice(turn.expectedCalls?.length);
|
|
64
|
+
return expectedHash === turn.expectedSelectionSha256
|
|
65
|
+
&& JSON.stringify(primaryCalls) === JSON.stringify(turn.expectedCalls)
|
|
66
|
+
&& (trailingCalls.length === 0
|
|
67
|
+
|| (trailingCalls.length === 1 && trailingCalls[0].name === "submit-feedback"));
|
|
68
|
+
}
|
|
69
|
+
if (typeof turn.expectedOptionalTool === "string" && turn.expectedOptionalTool.trim()) {
|
|
70
|
+
const expectedHash = createHash("sha256").update(JSON.stringify({
|
|
71
|
+
optionalTool: turn.expectedOptionalTool,
|
|
72
|
+
})).digest("hex");
|
|
73
|
+
return expectedHash === turn.expectedSelectionSha256
|
|
74
|
+
&& (turn.toolCalls.length === 0
|
|
75
|
+
|| (turn.toolCalls.length === 1 && turn.toolCalls[0].name === turn.expectedOptionalTool));
|
|
76
|
+
}
|
|
77
|
+
if (JSON.stringify(turn.selectedTools) !== JSON.stringify(turn.expectedTools)) return false;
|
|
78
|
+
if (turn.expectedToolAlternatives !== undefined) {
|
|
79
|
+
const alternatives = turn.expectedToolAlternatives;
|
|
80
|
+
if (!Array.isArray(alternatives) || alternatives.length < 1 || alternatives.length > 8
|
|
81
|
+
|| !alternatives.every((names) => Array.isArray(names)
|
|
82
|
+
&& names.every((name) => typeof name === "string" && name.trim()))
|
|
83
|
+
|| !alternatives.some((names) => JSON.stringify(names) === JSON.stringify(turn.expectedTools))) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (isHash(turn.expectedCallsSha256)) {
|
|
88
|
+
return JSON.stringify(turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))
|
|
89
|
+
=== JSON.stringify(turn.expectedCalls);
|
|
90
|
+
}
|
|
91
|
+
if (!isHash(turn.expectedSelectionSha256)) return false;
|
|
92
|
+
const expectedHash = createHash("sha256").update(JSON.stringify({
|
|
93
|
+
tools: turn.expectedTools,
|
|
94
|
+
arguments: turn.expectedArguments,
|
|
95
|
+
feedback: turn.expectedFeedback,
|
|
96
|
+
...(turn.expectedToolAlternatives === undefined
|
|
97
|
+
? {} : { alternatives: turn.expectedToolAlternatives }),
|
|
98
|
+
})).digest("hex");
|
|
99
|
+
if (expectedHash !== turn.expectedSelectionSha256) return false;
|
|
100
|
+
for (const [name, expected] of Object.entries(turn.expectedArguments ?? {})) {
|
|
101
|
+
const matches = turn.toolCalls.filter((call) => call.name === name);
|
|
102
|
+
if (matches.length !== 1 || JSON.stringify(matches[0].arguments) !== JSON.stringify(expected)) return false;
|
|
103
|
+
}
|
|
104
|
+
if (turn.expectedFeedback) {
|
|
105
|
+
const calls = turn.toolCalls.filter((call) => call.name === "submit-feedback");
|
|
106
|
+
const detail = calls[0]?.arguments?.detail;
|
|
107
|
+
if (calls.length !== 1 || !turn.expectedFeedback.observation.includes(calls[0].arguments?.observation)
|
|
108
|
+
|| typeof detail !== "string" || turn.expectedFeedback.detailIncludes.some(
|
|
109
|
+
(value) => !detail.toLowerCase().includes(value.toLowerCase()),
|
|
110
|
+
)) return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validNegativeFeedbackAssertion(turn) {
|
|
116
|
+
if (turn.expectedNegativeFeedback !== false) return true;
|
|
117
|
+
const actual = turn.toolCalls.filter((call) => call.name === "submit-feedback"
|
|
118
|
+
&& negativeFeedbackObservations.has(call.arguments?.observation));
|
|
119
|
+
return actual.length === 0 && JSON.stringify(actual) === JSON.stringify(turn.negativeFeedbackCalls);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validFeedbackDisclosure(turn, judgments, trial, isHash) {
|
|
123
|
+
if (turn.expectedOptionalFeedback !== true) return turn.feedbackDisclosure === undefined;
|
|
124
|
+
const feedback = turn.toolCalls[turn.expectedCalls.length];
|
|
125
|
+
if (!feedback) return turn.feedbackDisclosure === undefined;
|
|
126
|
+
if (feedback.isError || typeof feedback.arguments?.observation !== "string"
|
|
127
|
+
|| !feedback.arguments.observation.trim() || typeof feedback.arguments.detail !== "string"
|
|
128
|
+
|| !feedback.arguments.detail.trim()) return false;
|
|
129
|
+
const expectedMeaning = feedbackDisclosureExpectation(feedback);
|
|
130
|
+
const expectationSha256 = createHash("sha256").update(expectedMeaning).digest("hex");
|
|
131
|
+
if (!isHash(turn.feedbackDisclosure?.expectationSha256)
|
|
132
|
+
|| JSON.stringify(turn.feedbackDisclosure) !== JSON.stringify({
|
|
133
|
+
observation: feedback.arguments.observation,
|
|
134
|
+
detail: feedback.arguments.detail,
|
|
135
|
+
expectationSha256,
|
|
136
|
+
})) return false;
|
|
137
|
+
const matching = judgments.filter((judgment) => judgment.trial === trial
|
|
138
|
+
&& judgment.turn === turn.turn && judgment.expectationSha256 === expectationSha256);
|
|
139
|
+
return matching.length === 3 && matching.every((judgment) =>
|
|
140
|
+
judgment.expectedMeaning === expectedMeaning && judgment.verdict?.pass === true);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function feedbackDisclosureExpectation(call) {
|
|
144
|
+
return "The final assistant response makes it clear that feedback was submitted, recorded, or noted, "
|
|
145
|
+
+ "and communicates the substance "
|
|
146
|
+
+ `of this specific ${call.arguments.observation} observation: ${call.arguments.detail} `
|
|
147
|
+
+ "A concise, faithful summary is enough. It need not repeat every example, field, or phrase.";
|
|
148
|
+
}
|
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)) {
|
package/semantic/test.mjs
CHANGED
|
@@ -496,7 +496,6 @@ function safeModelFailure(error) {
|
|
|
496
496
|
"Model command did not resume the required session",
|
|
497
497
|
"Model command reported a failed MCP tool call",
|
|
498
498
|
"Model command exposed a configured secret",
|
|
499
|
-
"Model command used more than three tools",
|
|
500
499
|
"Model conversation already has a pending turn",
|
|
501
500
|
"Model conversation could not start",
|
|
502
501
|
"Model conversation is closed",
|