@emseepea/testing 0.0.0 → 0.0.1
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 +43 -3
- package/package.json +37 -2
- package/semantic/case.mjs +189 -0
- package/semantic/cli.mjs +394 -0
- package/semantic/material.mjs +130 -0
- package/semantic/provider.mjs +190 -0
package/README.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@emseepea/testing`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Test what a Model Context Protocol (MCP) server returns and whether a language
|
|
4
|
+
model understands it.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
The package starts a Streamable HTTP server, connects through the official MCP
|
|
7
|
+
client, and records the MCP operations used by each check. Semantic checks use
|
|
8
|
+
Promptfoo and a model command supplied by the caller.
|
|
9
|
+
|
|
10
|
+
The package is intended to publish under the `next` tag with the first
|
|
11
|
+
pre-alpha release.
|
|
12
|
+
|
|
13
|
+
## Add Semantic Checks to an Example
|
|
14
|
+
|
|
15
|
+
Install the package as a development dependency:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install --save-dev @emseepea/testing@next
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Create `eval.yaml` beside the example. Say which server to start, which MCP
|
|
22
|
+
operation to run, and what a correct answer must mean:
|
|
23
|
+
|
|
24
|
+
```yaml
|
|
25
|
+
description: Coffee details keep each concept distinct
|
|
26
|
+
server: ./dist/server.js
|
|
27
|
+
operations:
|
|
28
|
+
- method: tools/call
|
|
29
|
+
name: get-bean-details
|
|
30
|
+
arguments:
|
|
31
|
+
name: Highland Bloom
|
|
32
|
+
question: What are this coffee's origin, variety, process, roast, and tasting notes?
|
|
33
|
+
criticalFacts: [Sample Highlands, Bourbon, natural, medium, berry, cocoa]
|
|
34
|
+
criteria: Keep origin, variety, process, roast, and tasting notes distinct.
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Build the example, then run:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npx emseepea-test eval.yaml
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The check calls the server through the official MCP client. It runs three
|
|
44
|
+
answers and three independent judgments per answer, and saves the evidence
|
|
45
|
+
under `artifacts/llm-eval/`.
|
package/package.json
CHANGED
|
@@ -1,11 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emseepea/testing",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "MCP integration and semantic testing helpers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/windyroad/emseepea.git",
|
|
9
9
|
"directory": "packages/testing"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/windyroad/emseepea#readme",
|
|
12
|
+
"bugs": "https://github.com/windyroad/emseepea/issues",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public",
|
|
15
|
+
"provenance": true,
|
|
16
|
+
"tag": "next"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"emseepea-test": "./semantic/cli.mjs"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"semantic",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.json",
|
|
36
|
+
"test": "npm run build && npm run test:built",
|
|
37
|
+
"test:built": "node --test test/*.test.mjs"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/client": "2.0.0",
|
|
41
|
+
"promptfoo": "0.122.1"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22"
|
|
10
45
|
}
|
|
11
46
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const methods = new Set(["tools/call", "resources/read", "prompts/get"]);
|
|
6
|
+
|
|
7
|
+
export async function loadSemanticCase(path) {
|
|
8
|
+
const absolutePath = path instanceof URL ? fileURLToPath(path) : resolve(path);
|
|
9
|
+
const value = parseCase(await readFile(absolutePath, "utf8"));
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail("must be an object");
|
|
11
|
+
for (const key of ["description", "question", "criteria", "server"]) {
|
|
12
|
+
if (typeof value[key] !== "string" || value[key].trim() === "") fail(`${key} must be text`);
|
|
13
|
+
}
|
|
14
|
+
if (!Array.isArray(value.criticalFacts) || value.criticalFacts.some((fact) => typeof fact !== "string" || !fact)) {
|
|
15
|
+
fail("criticalFacts must be a non-empty text list");
|
|
16
|
+
}
|
|
17
|
+
if (!Array.isArray(value.operations) || value.operations.length === 0) fail("operations must not be empty");
|
|
18
|
+
for (const operation of value.operations) {
|
|
19
|
+
if (!operation || typeof operation !== "object" || !methods.has(operation.method)) {
|
|
20
|
+
fail("each operation needs a supported method");
|
|
21
|
+
}
|
|
22
|
+
const target = operation.name ?? operation.uri;
|
|
23
|
+
if (typeof target !== "string" || target === "") fail("each operation needs a name or uri");
|
|
24
|
+
}
|
|
25
|
+
if (value.environment !== undefined && (!value.environment || typeof value.environment !== "object" || Array.isArray(value.environment))) {
|
|
26
|
+
fail("environment must be an object");
|
|
27
|
+
}
|
|
28
|
+
if (value.authTokenEnvironment !== undefined && typeof value.authTokenEnvironment !== "string") {
|
|
29
|
+
fail("authTokenEnvironment must be text");
|
|
30
|
+
}
|
|
31
|
+
if (value.authToken !== undefined && typeof value.authToken !== "string") fail("authToken must be text");
|
|
32
|
+
return {
|
|
33
|
+
...value,
|
|
34
|
+
path: absolutePath,
|
|
35
|
+
directory: dirname(absolutePath),
|
|
36
|
+
server: resolve(dirname(absolutePath), value.server),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function requiredPaths(testCase) {
|
|
41
|
+
return testCase.operations.map((operation) => (
|
|
42
|
+
`${operation.method}:${operation.name ?? operation.uri}`
|
|
43
|
+
));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fail(message) {
|
|
47
|
+
throw new Error(`Invalid semantic case: ${message}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseCase(source) {
|
|
51
|
+
const lines = source.split(/\r?\n/);
|
|
52
|
+
const value = {};
|
|
53
|
+
for (let index = 0; index < lines.length;) {
|
|
54
|
+
const line = lines[index];
|
|
55
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
56
|
+
index += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const top = /^([A-Za-z][A-Za-z0-9]*):(?:\s*(.*))?$/.exec(line);
|
|
60
|
+
if (!top) fail(`unsupported line: ${line}`);
|
|
61
|
+
const [, key, raw = ""] = top;
|
|
62
|
+
const rest = raw.trimEnd();
|
|
63
|
+
if (key === "operations") {
|
|
64
|
+
const parsed = parseOperations(lines, index + 1);
|
|
65
|
+
value.operations = parsed.value;
|
|
66
|
+
index = parsed.next;
|
|
67
|
+
} else if (rest === ">-") {
|
|
68
|
+
const parsed = parseFoldedText(lines, index + 1);
|
|
69
|
+
value[key] = parsed.value;
|
|
70
|
+
index = parsed.next;
|
|
71
|
+
} else if (key === "environment" && rest === "") {
|
|
72
|
+
const parsed = parseObject(lines, index + 1, " ");
|
|
73
|
+
value.environment = parsed.value;
|
|
74
|
+
index = parsed.next;
|
|
75
|
+
} else if (rest === "") {
|
|
76
|
+
const parsed = parseList(lines, index + 1);
|
|
77
|
+
value[key] = parsed.value;
|
|
78
|
+
index = parsed.next;
|
|
79
|
+
} else if (rest.startsWith("[") && rest.endsWith("]")) {
|
|
80
|
+
value[key] = rest.slice(1, -1).split(",").map((item) => unquote(item.trim())).filter(Boolean);
|
|
81
|
+
index += 1;
|
|
82
|
+
} else {
|
|
83
|
+
value[key] = unquote(rest);
|
|
84
|
+
index += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseOperations(lines, start) {
|
|
91
|
+
const operations = [];
|
|
92
|
+
let index = start;
|
|
93
|
+
while (index < lines.length) {
|
|
94
|
+
const line = lines[index];
|
|
95
|
+
if (line.trim() === "") {
|
|
96
|
+
index += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!line.startsWith(" - ")) break;
|
|
100
|
+
const operation = {};
|
|
101
|
+
const first = line.slice(4);
|
|
102
|
+
applyPair(operation, first);
|
|
103
|
+
index += 1;
|
|
104
|
+
while (index < lines.length) {
|
|
105
|
+
const current = lines[index];
|
|
106
|
+
if (current.trim() === "") {
|
|
107
|
+
index += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (current.startsWith(" - ") || !current.startsWith(" ")) break;
|
|
111
|
+
const pair = /^ ([A-Za-z][A-Za-z0-9]*):(?:\s*(.*))?$/.exec(current);
|
|
112
|
+
if (!pair) fail(`unsupported operation line: ${current}`);
|
|
113
|
+
const [, key, raw = ""] = pair;
|
|
114
|
+
if (key === "arguments" && raw.trimEnd() === "") {
|
|
115
|
+
const parsed = parseObject(lines, index + 1, " ", true);
|
|
116
|
+
operation.arguments = parsed.value;
|
|
117
|
+
index = parsed.next;
|
|
118
|
+
} else {
|
|
119
|
+
operation[key] = unquote(raw.trimEnd());
|
|
120
|
+
index += 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
operations.push(operation);
|
|
124
|
+
}
|
|
125
|
+
return { value: operations, next: index };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseList(lines, start) {
|
|
129
|
+
const items = [];
|
|
130
|
+
let index = start;
|
|
131
|
+
while (index < lines.length) {
|
|
132
|
+
const item = /^ -\s+(.+)$/.exec(lines[index]);
|
|
133
|
+
if (!item) break;
|
|
134
|
+
items.push(unquote(item[1].trimEnd()));
|
|
135
|
+
index += 1;
|
|
136
|
+
}
|
|
137
|
+
if (items.length === 0) fail("expected a list");
|
|
138
|
+
return { value: items, next: index };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseObject(lines, start, indent, parseScalars = false) {
|
|
142
|
+
const object = {};
|
|
143
|
+
let index = start;
|
|
144
|
+
while (index < lines.length) {
|
|
145
|
+
if (!lines[index].startsWith(indent)) break;
|
|
146
|
+
applyPair(object, lines[index].slice(indent.length), parseScalars);
|
|
147
|
+
index += 1;
|
|
148
|
+
}
|
|
149
|
+
return { value: object, next: index };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseFoldedText(lines, start) {
|
|
153
|
+
const parts = [];
|
|
154
|
+
let index = start;
|
|
155
|
+
while (index < lines.length) {
|
|
156
|
+
if (!lines[index].startsWith(" ")) break;
|
|
157
|
+
parts.push(lines[index].trim());
|
|
158
|
+
index += 1;
|
|
159
|
+
}
|
|
160
|
+
return { value: parts.join(" "), next: index };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function applyPair(target, source, parseScalars = false) {
|
|
164
|
+
const pair = /^([A-Za-z][A-Za-z0-9_]*):\s*(.*)$/.exec(source);
|
|
165
|
+
if (!pair) fail(`expected key-value pair: ${source}`);
|
|
166
|
+
target[pair[1]] = parseScalars ? parseArgumentScalar(pair[2]) : unquote(pair[2].trimEnd());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseArgumentScalar(value) {
|
|
170
|
+
const trimmed = value.trim();
|
|
171
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
172
|
+
return unquote(trimmed);
|
|
173
|
+
}
|
|
174
|
+
if (trimmed === "true") return true;
|
|
175
|
+
if (trimmed === "false") return false;
|
|
176
|
+
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(trimmed)) {
|
|
177
|
+
const number = Number(trimmed);
|
|
178
|
+
if (Number.isFinite(number)) return number;
|
|
179
|
+
}
|
|
180
|
+
return trimmed;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function unquote(value) {
|
|
184
|
+
const trimmed = value.trim();
|
|
185
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
186
|
+
return trimmed.slice(1, -1);
|
|
187
|
+
}
|
|
188
|
+
return trimmed;
|
|
189
|
+
}
|
package/semantic/cli.mjs
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
import { loadSemanticCase, requiredPaths } from "./case.mjs";
|
|
11
|
+
import { collectMcpMaterial, startSemanticServer, stopSemanticServer } from "./material.mjs";
|
|
12
|
+
import { parseJudgeVerdict, runModel } from "./provider.mjs";
|
|
13
|
+
|
|
14
|
+
const options = parseArguments(process.argv.slice(2));
|
|
15
|
+
const outputPath = resolve(options.output ?? "artifacts/llm-eval/evidence.json");
|
|
16
|
+
const providerPath = fileURLToPath(new URL("./provider.mjs", import.meta.url));
|
|
17
|
+
const promptfooPath = join(dirname(fileURLToPath(import.meta.resolve("promptfoo"))), "entrypoint.js");
|
|
18
|
+
const startedAt = new Date().toISOString();
|
|
19
|
+
const evidence = {
|
|
20
|
+
authoritative: options.provider === "claude-ci",
|
|
21
|
+
cases: {},
|
|
22
|
+
errors: [],
|
|
23
|
+
finishedAt: undefined,
|
|
24
|
+
model: "claude-sonnet-4-6",
|
|
25
|
+
provider: options.provider,
|
|
26
|
+
semanticRetries: 0,
|
|
27
|
+
startedAt,
|
|
28
|
+
status: "failed",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
32
|
+
for (const path of options.casePaths) {
|
|
33
|
+
try {
|
|
34
|
+
const result = options.smoke ? await runSmokeCase(path) : await runSemanticCase(path);
|
|
35
|
+
evidence.cases[result.path] = result.evidence;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
evidence.errors.push(error instanceof Error ? error.message : String(error));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
evidence.finishedAt = new Date().toISOString();
|
|
41
|
+
evidence.status = evidence.errors.length === 0 ? "passed" : "failed";
|
|
42
|
+
await writeFile(outputPath, `${JSON.stringify(evidence, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
43
|
+
|
|
44
|
+
if (evidence.errors.length > 0) {
|
|
45
|
+
process.stderr.write(`Semantic evaluation failed; evidence: ${outputPath}\n`);
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
} else {
|
|
48
|
+
process.stdout.write(`Semantic evaluation passed for ${options.casePaths.length} case(s); evidence: ${outputPath}\n`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function runSemanticCase(path) {
|
|
52
|
+
const testCase = await loadSemanticCase(path);
|
|
53
|
+
const directory = await mkdtemp(join(tmpdir(), "emseepea-semantic-"));
|
|
54
|
+
const configPath = join(directory, "promptfoo.json");
|
|
55
|
+
const promptfooOutput = join(directory, "promptfoo-output.json");
|
|
56
|
+
const providerEvidence = join(directory, "provider.jsonl");
|
|
57
|
+
const config = promptfooConfig(testCase);
|
|
58
|
+
await writeFile(configPath, JSON.stringify(config), { encoding: "utf8", mode: 0o600 });
|
|
59
|
+
try {
|
|
60
|
+
const run = await runProcess(process.execPath, [
|
|
61
|
+
promptfooPath,
|
|
62
|
+
"eval",
|
|
63
|
+
"--config", configPath,
|
|
64
|
+
"--repeat", "3",
|
|
65
|
+
"--max-concurrency", "1",
|
|
66
|
+
"--no-cache",
|
|
67
|
+
"--no-share",
|
|
68
|
+
"--no-write",
|
|
69
|
+
"--no-table",
|
|
70
|
+
"--no-progress-bar",
|
|
71
|
+
"--output", promptfooOutput,
|
|
72
|
+
], {
|
|
73
|
+
cwd: directory,
|
|
74
|
+
env: cleanEnvironment({
|
|
75
|
+
CLAUDE_CODE_OAUTH_TOKEN: options.provider === "claude-ci"
|
|
76
|
+
? process.env.CLAUDE_CODE_OAUTH_TOKEN
|
|
77
|
+
: undefined,
|
|
78
|
+
EMSEEPEA_EVAL_PROVIDER: options.provider,
|
|
79
|
+
EMSEEPEA_EVAL_PROVIDER_EVIDENCE: providerEvidence,
|
|
80
|
+
EMSEEPEA_MODEL_COMMAND: options.modelCommand,
|
|
81
|
+
PROMPTFOO_CONFIG_DIR: directory,
|
|
82
|
+
}),
|
|
83
|
+
timeoutMs: 38 * 60_000,
|
|
84
|
+
});
|
|
85
|
+
if (run.timedOut) throw new Error(`${testCase.description}: Promptfoo timed out`);
|
|
86
|
+
const records = readJsonLines(await readFile(providerEvidence, "utf8"));
|
|
87
|
+
const entries = promptfooEntries(JSON.parse(await readFile(promptfooOutput, "utf8")));
|
|
88
|
+
if (run.code !== 0) {
|
|
89
|
+
if (process.env.EMSEEPEA_DEBUG === "1") process.stderr.write(run.stderr.slice(-4_000));
|
|
90
|
+
throw new Error(`${testCase.description}: Promptfoo exited ${run.code}`);
|
|
91
|
+
}
|
|
92
|
+
verifyProviderRecords(testCase, records);
|
|
93
|
+
if (entries.length !== 3 || entries.some(({ success }) => success !== true)) {
|
|
94
|
+
throw new Error(`${testCase.description}: ${entries.filter(({ success }) => success).length}/${entries.length} trials passed`);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
path: testCase.path,
|
|
98
|
+
evidence: {
|
|
99
|
+
agentTrials: records.filter(({ role }) => role === "agent"),
|
|
100
|
+
configSha256: createHash("sha256").update(JSON.stringify(config)).digest("hex"),
|
|
101
|
+
judgeVerdicts: records.filter(({ role }) => role === "judge"),
|
|
102
|
+
promptfooTrials: entries.map(({ description, score, success }) => ({ description, score, success })),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
} finally {
|
|
106
|
+
await rm(directory, { force: true, recursive: true });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function promptfooConfig(testCase) {
|
|
111
|
+
const judge = {
|
|
112
|
+
id: `file://${providerPath}`,
|
|
113
|
+
config: { maxRetries: 0, role: "judge" },
|
|
114
|
+
};
|
|
115
|
+
const rubric = [
|
|
116
|
+
"Judge the meaning of this answer against every criterion below.",
|
|
117
|
+
"A pass requires every criterion. Ignore harmless wording differences.",
|
|
118
|
+
"",
|
|
119
|
+
"Criteria:",
|
|
120
|
+
"{{criteria}}",
|
|
121
|
+
"",
|
|
122
|
+
"Answer:",
|
|
123
|
+
"<answer>",
|
|
124
|
+
"{{output}}",
|
|
125
|
+
"</answer>",
|
|
126
|
+
"",
|
|
127
|
+
"Return only JSON with this exact shape:",
|
|
128
|
+
'{"pass": true or false, "score": 1 or 0, "reason": "one concise sentence"}',
|
|
129
|
+
].join("\n");
|
|
130
|
+
const judgeAssertion = () => ({
|
|
131
|
+
type: "llm-rubric",
|
|
132
|
+
value: testCase.criteria,
|
|
133
|
+
provider: judge,
|
|
134
|
+
rubricPrompt: rubric,
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
description: testCase.description,
|
|
138
|
+
providers: [{
|
|
139
|
+
id: `file://${providerPath}`,
|
|
140
|
+
config: { maxRetries: 0, role: "agent" },
|
|
141
|
+
}],
|
|
142
|
+
prompts: ["{{question}}"],
|
|
143
|
+
defaultTest: {
|
|
144
|
+
options: {
|
|
145
|
+
timeoutMs: 120_000,
|
|
146
|
+
},
|
|
147
|
+
assert: [
|
|
148
|
+
{
|
|
149
|
+
type: "javascript",
|
|
150
|
+
value: [
|
|
151
|
+
"const actual = context.metadata?.pathEvidence ?? [];",
|
|
152
|
+
"const seen = new Set(actual.map(({ method, target }) => `${method}:${target}`));",
|
|
153
|
+
"const missing = JSON.parse(context.vars.requiredPaths).filter((path) => !seen.has(path));",
|
|
154
|
+
"return missing.length === 0",
|
|
155
|
+
" ? { pass: true, score: 1, reason: 'required MCP path evidence recorded' }",
|
|
156
|
+
" : { pass: false, score: 0, reason: `missing MCP path evidence: ${missing.join(', ')}` };",
|
|
157
|
+
].join("\n"),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
type: "javascript",
|
|
161
|
+
value: [
|
|
162
|
+
"const answer = String(output).toLowerCase();",
|
|
163
|
+
"const missing = JSON.parse(context.vars.criticalFacts)",
|
|
164
|
+
" .filter((fact) => !answer.includes(fact.toLowerCase()));",
|
|
165
|
+
"return missing.length === 0",
|
|
166
|
+
" ? { pass: true, score: 1, reason: 'critical facts present' }",
|
|
167
|
+
" : { pass: false, score: 0, reason: `missing critical facts: ${missing.join(', ')}` };",
|
|
168
|
+
].join("\n"),
|
|
169
|
+
},
|
|
170
|
+
judgeAssertion(),
|
|
171
|
+
judgeAssertion(),
|
|
172
|
+
judgeAssertion(),
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
tests: [{
|
|
176
|
+
description: testCase.description,
|
|
177
|
+
vars: {
|
|
178
|
+
casePath: testCase.path,
|
|
179
|
+
criteria: testCase.criteria,
|
|
180
|
+
criticalFacts: JSON.stringify(testCase.criticalFacts),
|
|
181
|
+
question: testCase.question,
|
|
182
|
+
requiredPaths: JSON.stringify(requiredPaths(testCase)),
|
|
183
|
+
},
|
|
184
|
+
}],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function runSmokeCase(path) {
|
|
189
|
+
const testCase = await loadSemanticCase(path);
|
|
190
|
+
const records = [];
|
|
191
|
+
const previousModelCommand = process.env.EMSEEPEA_MODEL_COMMAND;
|
|
192
|
+
process.env.EMSEEPEA_MODEL_COMMAND = options.modelCommand;
|
|
193
|
+
try {
|
|
194
|
+
for (let trial = 1; trial <= 3; trial += 1) {
|
|
195
|
+
const directory = await mkdtemp(join(tmpdir(), "emseepea-agent-"));
|
|
196
|
+
let running;
|
|
197
|
+
try {
|
|
198
|
+
running = await startSemanticServer(testCase);
|
|
199
|
+
const material = await collectMcpMaterial(running.url, testCase);
|
|
200
|
+
const prompt = `${material.text}\n\nAnswer only from that MCP material.\n\nQuestion:\n${testCase.question}`;
|
|
201
|
+
const result = await runModel(options.provider, prompt, directory);
|
|
202
|
+
const agentRecord = {
|
|
203
|
+
casePath: testCase.path,
|
|
204
|
+
materialSha256: createHash("sha256").update(material.text).digest("hex"),
|
|
205
|
+
models: result.models,
|
|
206
|
+
pathEvidence: material.pathEvidence,
|
|
207
|
+
provider: options.provider,
|
|
208
|
+
role: "agent",
|
|
209
|
+
status: "completed",
|
|
210
|
+
toolCallCount: 0,
|
|
211
|
+
trial,
|
|
212
|
+
turnCount: result.turnCount,
|
|
213
|
+
};
|
|
214
|
+
records.push(agentRecord);
|
|
215
|
+
const answer = result.answer;
|
|
216
|
+
for (let judgment = 1; judgment <= 3; judgment += 1) {
|
|
217
|
+
const judgeDirectory = await mkdtemp(join(tmpdir(), "emseepea-judge-"));
|
|
218
|
+
try {
|
|
219
|
+
const judgeResult = await runModel(options.provider, judgePrompt(testCase, answer), judgeDirectory);
|
|
220
|
+
records.push({
|
|
221
|
+
casePath: testCase.path,
|
|
222
|
+
models: judgeResult.models,
|
|
223
|
+
provider: options.provider,
|
|
224
|
+
role: "judge",
|
|
225
|
+
status: "completed",
|
|
226
|
+
trial: ((trial - 1) * 3) + judgment,
|
|
227
|
+
turnCount: judgeResult.turnCount,
|
|
228
|
+
verdict: parseJudgeVerdict(judgeResult.answer.trim()),
|
|
229
|
+
});
|
|
230
|
+
} finally {
|
|
231
|
+
await rm(judgeDirectory, { force: true, recursive: true });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
if (running) await stopSemanticServer(running.child);
|
|
236
|
+
await rm(directory, { force: true, recursive: true });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} finally {
|
|
240
|
+
if (previousModelCommand === undefined) delete process.env.EMSEEPEA_MODEL_COMMAND;
|
|
241
|
+
else process.env.EMSEEPEA_MODEL_COMMAND = previousModelCommand;
|
|
242
|
+
}
|
|
243
|
+
verifyProviderRecords(testCase, records);
|
|
244
|
+
return {
|
|
245
|
+
path: testCase.path,
|
|
246
|
+
evidence: {
|
|
247
|
+
agentTrials: records.filter(({ role }) => role === "agent"),
|
|
248
|
+
configSha256: createHash("sha256").update(JSON.stringify(promptfooConfig(testCase))).digest("hex"),
|
|
249
|
+
judgeVerdicts: records.filter(({ role }) => role === "judge"),
|
|
250
|
+
promptfooTrials: [{ description: testCase.description, score: 1, success: true }],
|
|
251
|
+
smoke: true,
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function judgePrompt(testCase, answer) {
|
|
257
|
+
return [
|
|
258
|
+
"Judge the meaning of this answer against every criterion below.",
|
|
259
|
+
"A pass requires every criterion. Ignore harmless wording differences.",
|
|
260
|
+
"",
|
|
261
|
+
"Criteria:",
|
|
262
|
+
testCase.criteria,
|
|
263
|
+
"",
|
|
264
|
+
"Answer:",
|
|
265
|
+
"<answer>",
|
|
266
|
+
answer,
|
|
267
|
+
"</answer>",
|
|
268
|
+
"",
|
|
269
|
+
"Return only JSON with this exact shape:",
|
|
270
|
+
'{"pass": true or false, "score": 1 or 0, "reason": "one concise sentence"}',
|
|
271
|
+
].join("\n");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function verifyProviderRecords(testCase, records) {
|
|
275
|
+
const agents = records.filter(({ role }) => role === "agent");
|
|
276
|
+
const judges = records.filter(({ role }) => role === "judge");
|
|
277
|
+
if (agents.length !== 3 || judges.length !== 9) {
|
|
278
|
+
throw new Error(`${testCase.description}: expected 3 answers and 9 judgments; got ${agents.length} and ${judges.length}`);
|
|
279
|
+
}
|
|
280
|
+
const required = requiredPaths(testCase);
|
|
281
|
+
if (agents.some(({ status, toolCallCount, materialSha256, pathEvidence, turnCount }) => (
|
|
282
|
+
status !== "completed"
|
|
283
|
+
|| toolCallCount !== 0
|
|
284
|
+
|| turnCount !== 1
|
|
285
|
+
|| !/^[a-f0-9]{64}$/.test(materialSha256 ?? "")
|
|
286
|
+
|| !Array.isArray(pathEvidence)
|
|
287
|
+
|| required.some((path) => !pathEvidence.some(({ method, target, requestSha256, responseSha256 }) => (
|
|
288
|
+
`${method}:${target}` === path
|
|
289
|
+
&& /^[a-f0-9]{64}$/.test(requestSha256 ?? "")
|
|
290
|
+
&& /^[a-f0-9]{64}$/.test(responseSha256 ?? "")
|
|
291
|
+
)))
|
|
292
|
+
))) throw new Error(`${testCase.description}: answer evidence is incomplete`);
|
|
293
|
+
if (judges.some(({ status, verdict, turnCount }) => (
|
|
294
|
+
status !== "completed" || turnCount !== 1 || verdict?.pass !== true
|
|
295
|
+
))) throw new Error(`${testCase.description}: a judgment failed`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parseArguments(args) {
|
|
299
|
+
const result = { casePaths: [], modelCommand: "claude", output: undefined, provider: "claude-local", smoke: false };
|
|
300
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
301
|
+
const value = args[index];
|
|
302
|
+
if (value === "--provider") result.provider = args[++index];
|
|
303
|
+
else if (value === "--model-command") result.modelCommand = args[++index];
|
|
304
|
+
else if (value === "--output") result.output = args[++index];
|
|
305
|
+
else if (value === "--smoke") result.smoke = true;
|
|
306
|
+
else if (value?.startsWith("--")) throw new Error(`Unknown option: ${value}`);
|
|
307
|
+
else result.casePaths.push(value);
|
|
308
|
+
}
|
|
309
|
+
if (!new Set(["claude-local", "claude-ci"]).has(result.provider)) {
|
|
310
|
+
throw new Error(`Unsupported provider: ${result.provider}`);
|
|
311
|
+
}
|
|
312
|
+
if (result.casePaths.length === 0) throw new Error("Pass at least one eval.yaml path");
|
|
313
|
+
if (result.modelCommand.includes("/") || result.modelCommand.includes("\\")) {
|
|
314
|
+
result.modelCommand = resolve(result.modelCommand);
|
|
315
|
+
}
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function cleanEnvironment(extra) {
|
|
320
|
+
return Object.fromEntries(Object.entries({
|
|
321
|
+
CI: "true",
|
|
322
|
+
HOME: process.env.HOME,
|
|
323
|
+
LANG: process.env.LANG ?? "C.UTF-8",
|
|
324
|
+
LOGNAME: process.env.LOGNAME,
|
|
325
|
+
NO_COLOR: "1",
|
|
326
|
+
PATH: process.env.PATH,
|
|
327
|
+
PROMPTFOO_CACHE_ENABLED: "false",
|
|
328
|
+
PROMPTFOO_DISABLE_REMOTE_GENERATION: "true",
|
|
329
|
+
PROMPTFOO_DISABLE_REDTEAM_REMOTE_GENERATION: "true",
|
|
330
|
+
PROMPTFOO_DISABLE_SHARING: "true",
|
|
331
|
+
PROMPTFOO_DISABLE_TELEMETRY: "true",
|
|
332
|
+
PROMPTFOO_DISABLE_TEMPLATE_ENV_VARS: "true",
|
|
333
|
+
PROMPTFOO_DISABLE_UPDATE: "true",
|
|
334
|
+
PROMPTFOO_SELF_HOSTED: "true",
|
|
335
|
+
SHELL: process.env.SHELL,
|
|
336
|
+
TMPDIR: process.env.TMPDIR,
|
|
337
|
+
USER: process.env.USER,
|
|
338
|
+
...extra,
|
|
339
|
+
}).filter(([, value]) => value !== undefined));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function runProcess(command, args, options = {}) {
|
|
343
|
+
const { timeoutMs, ...spawnOptions } = options;
|
|
344
|
+
return new Promise((resolve) => {
|
|
345
|
+
const grouped = process.platform !== "win32";
|
|
346
|
+
const child = spawn(command, args, { detached: grouped, stdio: ["ignore", "pipe", "pipe"], ...spawnOptions });
|
|
347
|
+
let stdout = "";
|
|
348
|
+
let stderr = "";
|
|
349
|
+
let timedOut = false;
|
|
350
|
+
const stop = () => {
|
|
351
|
+
try {
|
|
352
|
+
if (grouped) process.kill(-child.pid, "SIGKILL");
|
|
353
|
+
else child.kill("SIGKILL");
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error?.code !== "ESRCH") throw error;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
const interrupted = () => stop();
|
|
359
|
+
process.once("SIGINT", interrupted);
|
|
360
|
+
process.once("SIGTERM", interrupted);
|
|
361
|
+
const timer = setTimeout(() => {
|
|
362
|
+
timedOut = true;
|
|
363
|
+
stop();
|
|
364
|
+
}, timeoutMs);
|
|
365
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
366
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
367
|
+
child.once("error", (error) => {
|
|
368
|
+
clearTimeout(timer);
|
|
369
|
+
process.off("SIGINT", interrupted);
|
|
370
|
+
process.off("SIGTERM", interrupted);
|
|
371
|
+
resolve({ code: 1, error: error.message, stderr, stdout, timedOut });
|
|
372
|
+
});
|
|
373
|
+
child.once("close", (code) => {
|
|
374
|
+
clearTimeout(timer);
|
|
375
|
+
process.off("SIGINT", interrupted);
|
|
376
|
+
process.off("SIGTERM", interrupted);
|
|
377
|
+
resolve({ code: code ?? 1, stderr, stdout, timedOut });
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function readJsonLines(source) {
|
|
383
|
+
return source.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function promptfooEntries(value, found = []) {
|
|
387
|
+
if (Array.isArray(value)) {
|
|
388
|
+
if (value.every((entry) => entry && typeof entry === "object" && "success" in entry)) found.push(...value);
|
|
389
|
+
else for (const entry of value) promptfooEntries(entry, found);
|
|
390
|
+
} else if (value && typeof value === "object") {
|
|
391
|
+
for (const entry of Object.values(value)) promptfooEntries(entry, found);
|
|
392
|
+
}
|
|
393
|
+
return found;
|
|
394
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
5
|
+
|
|
6
|
+
const sha256 = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
7
|
+
|
|
8
|
+
export async function startSemanticServer(testCase) {
|
|
9
|
+
const child = spawn(process.execPath, [testCase.server], {
|
|
10
|
+
cwd: testCase.directory,
|
|
11
|
+
env: serverEnvironment(testCase.environment),
|
|
12
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
13
|
+
});
|
|
14
|
+
let output = "";
|
|
15
|
+
child.stdout.on("data", (chunk) => { output = `${output}${chunk}`.slice(-16_384); });
|
|
16
|
+
try {
|
|
17
|
+
const url = await new Promise((resolve, reject) => {
|
|
18
|
+
const finish = (error, value) => {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
child.stdout.off("data", inspect);
|
|
21
|
+
child.off("error", failed);
|
|
22
|
+
child.off("exit", exited);
|
|
23
|
+
if (error) reject(error);
|
|
24
|
+
else resolve(value);
|
|
25
|
+
};
|
|
26
|
+
const inspect = () => {
|
|
27
|
+
const match = output.match(/http:\/\/127\.0\.0\.1:\d+\/mcp/);
|
|
28
|
+
if (match?.[0]) finish(undefined, match[0]);
|
|
29
|
+
};
|
|
30
|
+
const failed = () => finish(new Error("MCP server did not start"));
|
|
31
|
+
const exited = () => finish(new Error("MCP server stopped during startup"));
|
|
32
|
+
const timer = setTimeout(() => finish(new Error("MCP server startup timed out")), 15_000);
|
|
33
|
+
child.stdout.on("data", inspect);
|
|
34
|
+
child.once("error", failed);
|
|
35
|
+
child.once("exit", exited);
|
|
36
|
+
inspect();
|
|
37
|
+
});
|
|
38
|
+
return { child, url };
|
|
39
|
+
} catch (error) {
|
|
40
|
+
await stopSemanticServer(child);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function stopSemanticServer(child) {
|
|
46
|
+
if (child.exitCode !== null) return;
|
|
47
|
+
child.kill("SIGTERM");
|
|
48
|
+
await Promise.race([
|
|
49
|
+
new Promise((resolve) => child.once("close", resolve)),
|
|
50
|
+
new Promise((resolve) => setTimeout(resolve, 3_000)),
|
|
51
|
+
]);
|
|
52
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function collectMcpMaterial(url, testCase) {
|
|
56
|
+
const token = testCase.authToken ?? (testCase.authTokenEnvironment
|
|
57
|
+
? process.env[testCase.authTokenEnvironment]?.trim()
|
|
58
|
+
: undefined);
|
|
59
|
+
if (testCase.authTokenEnvironment && !token) {
|
|
60
|
+
throw new Error(`Required authentication is unavailable: ${testCase.authTokenEnvironment}`);
|
|
61
|
+
}
|
|
62
|
+
const client = new Client(
|
|
63
|
+
{ name: "emseepea-semantic-test", version: "0.0.0" },
|
|
64
|
+
{ versionNegotiation: { mode: { pin: "2026-07-28" } } },
|
|
65
|
+
);
|
|
66
|
+
await client.connect(new StreamableHTTPClientTransport(
|
|
67
|
+
new URL(url),
|
|
68
|
+
token ? { authProvider: { token: async () => token } } : undefined,
|
|
69
|
+
));
|
|
70
|
+
const evidence = [];
|
|
71
|
+
const material = [];
|
|
72
|
+
try {
|
|
73
|
+
for (const operation of testCase.operations) {
|
|
74
|
+
const request = requestFor(operation);
|
|
75
|
+
const response = await perform(client, operation);
|
|
76
|
+
evidence.push({
|
|
77
|
+
method: operation.method,
|
|
78
|
+
target: operation.name ?? operation.uri,
|
|
79
|
+
requestSha256: sha256(request),
|
|
80
|
+
responseSha256: sha256(response),
|
|
81
|
+
});
|
|
82
|
+
material.push(`Operation: ${JSON.stringify(request)}\nResult: ${JSON.stringify(response)}`);
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
await client.close();
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
pathEvidence: evidence,
|
|
89
|
+
text: ["The following material was retrieved through the official MCP client.", ...material].join("\n\n"),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requestFor(operation) {
|
|
94
|
+
if (operation.method === "tools/call") {
|
|
95
|
+
return { method: operation.method, name: operation.name, arguments: operation.arguments ?? {} };
|
|
96
|
+
}
|
|
97
|
+
if (operation.method === "resources/read") return { method: operation.method, uri: operation.uri };
|
|
98
|
+
return { method: operation.method, name: operation.name, arguments: operation.arguments ?? {} };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function perform(client, operation) {
|
|
102
|
+
if (operation.method === "tools/call") {
|
|
103
|
+
const progress = [];
|
|
104
|
+
const result = await client.callTool(
|
|
105
|
+
{ name: operation.name, arguments: operation.arguments ?? {} },
|
|
106
|
+
{ onprogress: (update) => progress.push(update) },
|
|
107
|
+
);
|
|
108
|
+
if (result.isError) throw new Error("MCP tool returned an error");
|
|
109
|
+
return { progress, result };
|
|
110
|
+
}
|
|
111
|
+
if (operation.method === "resources/read") return client.readResource({ uri: operation.uri });
|
|
112
|
+
return client.getPrompt({ name: operation.name, arguments: operation.arguments ?? {} });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function serverEnvironment(extra = {}) {
|
|
116
|
+
return Object.fromEntries(Object.entries({
|
|
117
|
+
CI: "true",
|
|
118
|
+
HOME: process.env.HOME,
|
|
119
|
+
LANG: process.env.LANG ?? "C.UTF-8",
|
|
120
|
+
LOGNAME: process.env.LOGNAME,
|
|
121
|
+
NODE_ENV: "test",
|
|
122
|
+
NO_COLOR: "1",
|
|
123
|
+
PATH: process.env.PATH,
|
|
124
|
+
PORT: "0",
|
|
125
|
+
SHELL: process.env.SHELL,
|
|
126
|
+
TMPDIR: process.env.TMPDIR,
|
|
127
|
+
USER: process.env.USER,
|
|
128
|
+
...extra,
|
|
129
|
+
}).filter(([, value]) => value !== undefined));
|
|
130
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { appendFile, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { loadSemanticCase } from "./case.mjs";
|
|
8
|
+
import { collectMcpMaterial, startSemanticServer, stopSemanticServer } from "./material.mjs";
|
|
9
|
+
|
|
10
|
+
const model = "claude-sonnet-4-6";
|
|
11
|
+
const counters = new Map();
|
|
12
|
+
|
|
13
|
+
export function parseClaudeEvents(stdout, processExitCode = 0) {
|
|
14
|
+
const events = stdout.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
|
|
15
|
+
const result = events.findLast(({ type }) => type === "result");
|
|
16
|
+
const notLoggedIn = events.some(({ message }) => (
|
|
17
|
+
Array.isArray(message?.content)
|
|
18
|
+
&& message.content.some(({ type, text }) => type === "text" && /not logged in/i.test(text ?? ""))
|
|
19
|
+
));
|
|
20
|
+
const toolUses = events.flatMap(({ message }) => (
|
|
21
|
+
Array.isArray(message?.content) ? message.content.filter(({ type }) => type === "tool_use") : []
|
|
22
|
+
));
|
|
23
|
+
if (notLoggedIn) throw new Error("Model command is not signed in");
|
|
24
|
+
if (processExitCode !== 0) throw new Error(`Model command exited ${processExitCode}`);
|
|
25
|
+
if (result?.is_error || typeof result?.result !== "string") throw new Error("Model command returned no answer");
|
|
26
|
+
if (toolUses.length > 0) throw new Error("Model command used a forbidden tool");
|
|
27
|
+
if (result.num_turns !== 1) throw new Error(`Model command used ${String(result.num_turns)} turns`);
|
|
28
|
+
if ((result.permission_denials?.length ?? 0) > 0) throw new Error("Model command attempted a forbidden action");
|
|
29
|
+
const usage = result.modelUsage?.[model];
|
|
30
|
+
if (usage?.canonicalModel !== model || usage.provider !== "firstParty") {
|
|
31
|
+
throw new Error("Model command did not use the required model");
|
|
32
|
+
}
|
|
33
|
+
return { answer: result.result, models: Object.keys(result.modelUsage), turnCount: result.num_turns };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseJudgeVerdict(output) {
|
|
37
|
+
const verdict = JSON.parse(output);
|
|
38
|
+
const keys = verdict && typeof verdict === "object" && !Array.isArray(verdict)
|
|
39
|
+
? Object.keys(verdict).sort()
|
|
40
|
+
: [];
|
|
41
|
+
if (
|
|
42
|
+
keys.join(",") !== "pass,reason,score"
|
|
43
|
+
|| !((verdict.pass === true && verdict.score === 1) || (verdict.pass === false && verdict.score === 0))
|
|
44
|
+
|| typeof verdict.reason !== "string"
|
|
45
|
+
|| verdict.reason.trim() === ""
|
|
46
|
+
) throw new Error("Judge returned an invalid verdict");
|
|
47
|
+
return verdict;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function modelInvocation(provider, prompt, directory) {
|
|
51
|
+
const token = provider === "claude-ci" ? process.env.CLAUDE_CODE_OAUTH_TOKEN?.trim() : undefined;
|
|
52
|
+
if (provider === "claude-ci" && !token) throw new Error("Claude subscription authentication is unavailable");
|
|
53
|
+
const localHome = provider === "claude-local" ? process.env.HOME : undefined;
|
|
54
|
+
if (provider === "claude-local" && !localHome?.startsWith("/")) {
|
|
55
|
+
throw new Error("Local model evaluation requires an absolute HOME");
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
command: process.env.EMSEEPEA_MODEL_COMMAND ?? "claude",
|
|
59
|
+
args: [
|
|
60
|
+
"--print", prompt,
|
|
61
|
+
"--model", model,
|
|
62
|
+
"--effort", "low",
|
|
63
|
+
"--safe-mode",
|
|
64
|
+
"--strict-mcp-config",
|
|
65
|
+
"--disable-slash-commands",
|
|
66
|
+
"--no-session-persistence",
|
|
67
|
+
"--permission-mode", "dontAsk",
|
|
68
|
+
"--setting-sources", "",
|
|
69
|
+
"--tools", "",
|
|
70
|
+
"--no-chrome",
|
|
71
|
+
"--prompt-suggestions", "false",
|
|
72
|
+
"--output-format", "stream-json",
|
|
73
|
+
"--verbose",
|
|
74
|
+
],
|
|
75
|
+
cwd: directory,
|
|
76
|
+
env: modelEnvironment(provider === "claude-ci"
|
|
77
|
+
? { CLAUDE_CONFIG_DIR: join(directory, "claude-config"), HOME: directory, CLAUDE_CODE_OAUTH_TOKEN: token }
|
|
78
|
+
: { HOME: localHome }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default class SemanticProvider {
|
|
83
|
+
constructor(options = {}) {
|
|
84
|
+
this.role = options.config?.role ?? "agent";
|
|
85
|
+
this.provider = process.env.EMSEEPEA_EVAL_PROVIDER ?? "claude-local";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
id() {
|
|
89
|
+
return `${this.provider}-${this.role}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async callApi(prompt, context = {}) {
|
|
93
|
+
const casePath = String(context.vars?.casePath ?? "");
|
|
94
|
+
const trial = nextTrial(`${casePath}:${this.role}`);
|
|
95
|
+
const directory = await mkdtemp(join(tmpdir(), `emseepea-${this.role}-`));
|
|
96
|
+
let running;
|
|
97
|
+
try {
|
|
98
|
+
let effectivePrompt = prompt;
|
|
99
|
+
let pathEvidence = [];
|
|
100
|
+
let materialSha256;
|
|
101
|
+
if (this.role === "agent") {
|
|
102
|
+
const testCase = await loadSemanticCase(casePath);
|
|
103
|
+
running = await startSemanticServer(testCase);
|
|
104
|
+
const material = await collectMcpMaterial(running.url, testCase);
|
|
105
|
+
effectivePrompt = `${material.text}\n\nAnswer only from that MCP material.\n\nQuestion:\n${prompt}`;
|
|
106
|
+
pathEvidence = material.pathEvidence;
|
|
107
|
+
materialSha256 = createHash("sha256").update(material.text).digest("hex");
|
|
108
|
+
}
|
|
109
|
+
const result = await runModel(this.provider, effectivePrompt, directory);
|
|
110
|
+
const output = this.role === "judge" ? result.answer.trim() : result.answer;
|
|
111
|
+
const verdict = this.role === "judge" ? parseJudgeVerdict(output) : undefined;
|
|
112
|
+
const metadata = {
|
|
113
|
+
casePath,
|
|
114
|
+
materialSha256,
|
|
115
|
+
models: result.models,
|
|
116
|
+
pathEvidence,
|
|
117
|
+
provider: this.provider,
|
|
118
|
+
role: this.role,
|
|
119
|
+
toolCallCount: 0,
|
|
120
|
+
trial,
|
|
121
|
+
turnCount: result.turnCount,
|
|
122
|
+
};
|
|
123
|
+
await recordEvidence({ ...metadata, status: "completed", verdict });
|
|
124
|
+
return { output, metadata };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
127
|
+
await recordEvidence({ casePath, error: message, provider: this.provider, role: this.role, status: "error", trial });
|
|
128
|
+
return { error: message };
|
|
129
|
+
} finally {
|
|
130
|
+
if (running) await stopSemanticServer(running.child);
|
|
131
|
+
await rm(directory, { force: true, recursive: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function runModel(provider, prompt, directory) {
|
|
137
|
+
const invocation = modelInvocation(provider, prompt, directory);
|
|
138
|
+
const execution = await runProcess(invocation.command, invocation.args, {
|
|
139
|
+
cwd: invocation.cwd,
|
|
140
|
+
env: invocation.env,
|
|
141
|
+
});
|
|
142
|
+
if (execution.timedOut) throw new Error("Model command timed out");
|
|
143
|
+
if (execution.code !== 0 && !execution.stdout) throw new Error(`Model command exited ${execution.code}`);
|
|
144
|
+
return parseClaudeEvents(execution.stdout, execution.code);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function runProcess(command, args, options) {
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], ...options });
|
|
150
|
+
let stdout = "";
|
|
151
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 120_000);
|
|
152
|
+
let timedOut = false;
|
|
153
|
+
timer.unref();
|
|
154
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
155
|
+
child.once("error", (error) => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
resolve({ code: 1, error: error.message, stdout, timedOut });
|
|
158
|
+
});
|
|
159
|
+
child.once("close", (code, signal) => {
|
|
160
|
+
if (signal === "SIGKILL") timedOut = true;
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
resolve({ code: code ?? 1, stdout, timedOut });
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function modelEnvironment(extra) {
|
|
168
|
+
return Object.fromEntries(Object.entries({
|
|
169
|
+
CI: "true",
|
|
170
|
+
LANG: process.env.LANG ?? "C.UTF-8",
|
|
171
|
+
LOGNAME: process.env.LOGNAME,
|
|
172
|
+
NO_COLOR: "1",
|
|
173
|
+
PATH: process.env.PATH,
|
|
174
|
+
SHELL: process.env.SHELL,
|
|
175
|
+
TMPDIR: process.env.TMPDIR,
|
|
176
|
+
USER: process.env.USER,
|
|
177
|
+
...extra,
|
|
178
|
+
}).filter(([, value]) => value !== undefined));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function nextTrial(key) {
|
|
182
|
+
const trial = (counters.get(key) ?? 0) + 1;
|
|
183
|
+
counters.set(key, trial);
|
|
184
|
+
return trial;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function recordEvidence(record) {
|
|
188
|
+
const path = process.env.EMSEEPEA_EVAL_PROVIDER_EVIDENCE;
|
|
189
|
+
if (path) await appendFile(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
190
|
+
}
|