@data-fair/lib-agents-sim 0.2.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 +165 -0
- package/bin/init.d.ts +2 -0
- package/bin/init.js +28 -0
- package/bridge/conversation.d.ts +68 -0
- package/bridge/conversation.js +167 -0
- package/bridge/index.d.ts +2 -0
- package/bridge/index.js +18 -0
- package/bridge/openai.d.ts +102 -0
- package/bridge/openai.js +91 -0
- package/bridge/server.d.ts +38 -0
- package/bridge/server.js +196 -0
- package/bridge/sessions.d.ts +46 -0
- package/bridge/sessions.js +109 -0
- package/bridge/tool-server.d.ts +60 -0
- package/bridge/tool-server.js +40 -0
- package/cases.d.ts +10 -0
- package/cases.js +18 -0
- package/chat-driver.d.ts +23 -0
- package/chat-driver.js +41 -0
- package/gateway-capture.d.ts +31 -0
- package/gateway-capture.js +64 -0
- package/index.d.ts +8 -0
- package/index.js +7 -0
- package/isolation.d.ts +16 -0
- package/isolation.js +37 -0
- package/missing-sdk.d.ts +16 -0
- package/missing-sdk.js +27 -0
- package/package.json +44 -0
- package/persona.d.ts +12 -0
- package/persona.js +96 -0
- package/report.d.ts +2 -0
- package/report.js +68 -0
- package/templates/simulate-skill.md +83 -0
- package/templates/simulation-judge.md +65 -0
- package/transcript.d.ts +9 -0
- package/transcript.js +21 -0
- package/types.d.ts +37 -0
- package/types.js +4 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function extractUserMessageText(content) {
|
|
2
|
+
if (typeof content === 'string')
|
|
3
|
+
return content;
|
|
4
|
+
if (content === null || content === undefined)
|
|
5
|
+
return '';
|
|
6
|
+
if (Array.isArray(content)) {
|
|
7
|
+
const texts = content
|
|
8
|
+
.filter((part) => typeof part === 'object' && part !== null && part.type === 'text')
|
|
9
|
+
.map((part) => part.text)
|
|
10
|
+
.filter((text) => typeof text === 'string');
|
|
11
|
+
if (texts.length > 0)
|
|
12
|
+
return texts.join(' ');
|
|
13
|
+
if (content.length > 0)
|
|
14
|
+
return JSON.stringify(content);
|
|
15
|
+
}
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
export function summariseRequest(body) {
|
|
19
|
+
if (typeof body !== 'object' || body === null)
|
|
20
|
+
return null;
|
|
21
|
+
const b = body;
|
|
22
|
+
if (!Array.isArray(b.messages))
|
|
23
|
+
return null;
|
|
24
|
+
const users = b.messages.filter(m => m.role === 'user');
|
|
25
|
+
const last = users[users.length - 1];
|
|
26
|
+
return {
|
|
27
|
+
at: Date.now(),
|
|
28
|
+
model: b.model ?? '',
|
|
29
|
+
toolNames: (b.tools ?? []).map(t => t.function?.name ?? '').filter(Boolean),
|
|
30
|
+
messageCount: b.messages.length,
|
|
31
|
+
lastUserMessage: extractUserMessageText(last?.content),
|
|
32
|
+
toolCalls: b.messages.flatMap(m => (m.tool_calls ?? []).map(c => ({
|
|
33
|
+
name: c.function?.name ?? '',
|
|
34
|
+
arguments: c.function?.arguments ?? ''
|
|
35
|
+
})))
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function captureGateway(page) {
|
|
39
|
+
const exchanges = [];
|
|
40
|
+
page.on('request', req => {
|
|
41
|
+
if (!req.url().includes('/v1/chat/completions'))
|
|
42
|
+
return;
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(req.postData() ?? '');
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
exchanges.push({
|
|
49
|
+
at: Date.now(),
|
|
50
|
+
model: '',
|
|
51
|
+
toolNames: [],
|
|
52
|
+
messageCount: 0,
|
|
53
|
+
lastUserMessage: '',
|
|
54
|
+
toolCalls: [],
|
|
55
|
+
unparsed: true
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const summary = summariseRequest(parsed);
|
|
60
|
+
if (summary)
|
|
61
|
+
exchanges.push(summary);
|
|
62
|
+
});
|
|
63
|
+
return exchanges;
|
|
64
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { SimulationCase, Transcript, RunSidecar } from './types.ts';
|
|
2
|
+
export { createNeutralCwd, scrubEnv, isolationOptions, type Env } from './isolation.ts';
|
|
3
|
+
export { captureGateway, summariseRequest, type GatewayExchange } from './gateway-capture.ts';
|
|
4
|
+
export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE } from './persona.ts';
|
|
5
|
+
export { writeEvidence, evidenceDir } from './transcript.ts';
|
|
6
|
+
export { selectCases } from './cases.ts';
|
|
7
|
+
export { reportCases } from './report.ts';
|
|
8
|
+
export { createChatDriver, type ChatRoot, TURN_TIMEOUT_MS } from './chat-driver.ts';
|
package/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createNeutralCwd, scrubEnv, isolationOptions } from "./isolation.js";
|
|
2
|
+
export { captureGateway, summariseRequest } from "./gateway-capture.js";
|
|
3
|
+
export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE } from "./persona.js";
|
|
4
|
+
export { writeEvidence, evidenceDir } from "./transcript.js";
|
|
5
|
+
export { selectCases } from "./cases.js";
|
|
6
|
+
export { reportCases } from "./report.js";
|
|
7
|
+
export { createChatDriver, TURN_TIMEOUT_MS } from "./chat-driver.js";
|
package/isolation.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function createNeutralCwd(): string;
|
|
2
|
+
/**
|
|
3
|
+
* Spelled structurally rather than as `NodeJS.ProcessEnv`, which would make
|
|
4
|
+
* `@types/node` an undeclared type dependency of this package: a consumer
|
|
5
|
+
* without it hits `TS2503: Cannot find namespace 'NodeJS'` on this .d.ts.
|
|
6
|
+
* `process.env` satisfies it, so nothing is lost at the call sites.
|
|
7
|
+
*/
|
|
8
|
+
export type Env = Record<string, string | undefined>;
|
|
9
|
+
export declare function scrubEnv(env: Env): Env;
|
|
10
|
+
export declare function isolationOptions(cwd: string, env?: Env): {
|
|
11
|
+
cwd: string;
|
|
12
|
+
env: Env;
|
|
13
|
+
settingSources: never[];
|
|
14
|
+
tools: never[];
|
|
15
|
+
strictMcpConfig: true;
|
|
16
|
+
};
|
package/isolation.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The isolation guarantee (spec §1.2).
|
|
3
|
+
*
|
|
4
|
+
* Measured, not assumed: with `settingSources: []` but the repository as cwd, the
|
|
5
|
+
* model answered with the project's auto-memory index — naming the bugs this
|
|
6
|
+
* tooling exists to find. Auto-memory is keyed to the project directory, so only
|
|
7
|
+
* a neutral cwd removes it. `tools: []` matters just as much: without it the SDK
|
|
8
|
+
* offers 27 built-in tools and the model reaches for ToolSearch instead of the
|
|
9
|
+
* tools the request actually declared.
|
|
10
|
+
*
|
|
11
|
+
* These options are fixed. Nothing in a request may override them.
|
|
12
|
+
*/
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
export function createNeutralCwd() {
|
|
17
|
+
// Deliberately meaningless name: ~367 tokens of SDK preamble are irreducible and
|
|
18
|
+
// include the cwd path, so the path itself must carry no signal about the product.
|
|
19
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'bridge-'));
|
|
20
|
+
}
|
|
21
|
+
export function scrubEnv(env) {
|
|
22
|
+
const scrubbed = { ...env };
|
|
23
|
+
for (const key of Object.keys(scrubbed)) {
|
|
24
|
+
if (key.startsWith('CLAUDE_CODE_'))
|
|
25
|
+
delete scrubbed[key];
|
|
26
|
+
}
|
|
27
|
+
return scrubbed;
|
|
28
|
+
}
|
|
29
|
+
export function isolationOptions(cwd, env = process.env) {
|
|
30
|
+
return {
|
|
31
|
+
cwd,
|
|
32
|
+
env: scrubEnv(env),
|
|
33
|
+
settingSources: [],
|
|
34
|
+
tools: [],
|
|
35
|
+
strictMcpConfig: true
|
|
36
|
+
};
|
|
37
|
+
}
|
package/missing-sdk.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agent SDK and MCP SDK are optional peers (see package.json) so a
|
|
3
|
+
* consumer who only wants the harness primitives (createChatDriver,
|
|
4
|
+
* captureGateway, selectCases, reportCases, ...) is not forced to install
|
|
5
|
+
* them. Two places load the Agent SDK lazily, inside the function that needs
|
|
6
|
+
* it rather than at module top level, so importing the barrel never requires
|
|
7
|
+
* it: `persona.ts`'s `nextUserMessage` (the simulated user) and the
|
|
8
|
+
* `df-agents-bridge` bin. Both catch a resulting ERR_MODULE_NOT_FOUND here
|
|
9
|
+
* and surface this message instead of node's raw error, naming the install
|
|
10
|
+
* command.
|
|
11
|
+
*
|
|
12
|
+
* Exported as a constant (not inlined) so a unit test can assert it still
|
|
13
|
+
* exists and still names the right packages.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MISSING_SDK_MESSAGE: string;
|
|
16
|
+
export declare function isMissingSdkError(err: unknown): boolean;
|
package/missing-sdk.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agent SDK and MCP SDK are optional peers (see package.json) so a
|
|
3
|
+
* consumer who only wants the harness primitives (createChatDriver,
|
|
4
|
+
* captureGateway, selectCases, reportCases, ...) is not forced to install
|
|
5
|
+
* them. Two places load the Agent SDK lazily, inside the function that needs
|
|
6
|
+
* it rather than at module top level, so importing the barrel never requires
|
|
7
|
+
* it: `persona.ts`'s `nextUserMessage` (the simulated user) and the
|
|
8
|
+
* `df-agents-bridge` bin. Both catch a resulting ERR_MODULE_NOT_FOUND here
|
|
9
|
+
* and surface this message instead of node's raw error, naming the install
|
|
10
|
+
* command.
|
|
11
|
+
*
|
|
12
|
+
* Exported as a constant (not inlined) so a unit test can assert it still
|
|
13
|
+
* exists and still names the right packages.
|
|
14
|
+
*/
|
|
15
|
+
export const MISSING_SDK_MESSAGE = [
|
|
16
|
+
'@data-fair/lib-agents-sim needs the Agent SDK and MCP SDK, which are optional peer dependencies and are not installed.',
|
|
17
|
+
'Install them with:',
|
|
18
|
+
' npm i -D @anthropic-ai/claude-agent-sdk @modelcontextprotocol/sdk'
|
|
19
|
+
].join('\n');
|
|
20
|
+
export function isMissingSdkError(err) {
|
|
21
|
+
if (!err || typeof err !== 'object')
|
|
22
|
+
return false;
|
|
23
|
+
if (err.code !== 'ERR_MODULE_NOT_FOUND')
|
|
24
|
+
return false;
|
|
25
|
+
const message = String(err.message ?? '');
|
|
26
|
+
return message.includes('@anthropic-ai/claude-agent-sdk') || message.includes('@modelcontextprotocol/sdk');
|
|
27
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@data-fair/lib-agents-sim",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Primitives for judged browser simulations of the data-fair agents chat, plus a Claude Code bridge exposing the Agent SDK as an OpenAI-compatible provider.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./index.d.ts",
|
|
10
|
+
"default": "./index.js"
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"df-agents-bridge": "bridge/index.js",
|
|
16
|
+
"df-agents-sim-init": "bin/init.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"**/*.js",
|
|
20
|
+
"**/*.d.ts",
|
|
21
|
+
"templates/**"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"prepublishOnly": "tsc"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@playwright/test": "^1.58.0",
|
|
30
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.269",
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"@playwright/test": {
|
|
35
|
+
"optional": true
|
|
36
|
+
},
|
|
37
|
+
"@anthropic-ai/claude-agent-sdk": {
|
|
38
|
+
"optional": true
|
|
39
|
+
},
|
|
40
|
+
"@modelcontextprotocol/sdk": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/persona.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SimulationCase } from './types.ts';
|
|
2
|
+
export declare const DONE = "DONE";
|
|
3
|
+
export declare function isDone(message: string): boolean;
|
|
4
|
+
export declare function personaSystemPrompt(c: SimulationCase): string;
|
|
5
|
+
export declare function personaPrompt(conversation: Array<{
|
|
6
|
+
role: string;
|
|
7
|
+
text: string;
|
|
8
|
+
}>, turnsLeft: number): string;
|
|
9
|
+
export declare function nextUserMessage(c: SimulationCase, conversation: Array<{
|
|
10
|
+
role: string;
|
|
11
|
+
text: string;
|
|
12
|
+
}>, turnsLeft: number): Promise<string>;
|
package/persona.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The simulated user.
|
|
3
|
+
*
|
|
4
|
+
* It is a person with a goal, not a test script: it may be vague, change its
|
|
5
|
+
* mind, or push back, which is what makes this a simulation rather than a
|
|
6
|
+
* fixture. It runs under the same isolation as every other Claude role here —
|
|
7
|
+
* launched from this repo it would inherit the auto-memory index and know the
|
|
8
|
+
* bugs the scenario exists to find (spec §1.2).
|
|
9
|
+
*/
|
|
10
|
+
import { createNeutralCwd, isolationOptions } from "./isolation.js";
|
|
11
|
+
import { MISSING_SDK_MESSAGE, isMissingSdkError } from "./missing-sdk.js";
|
|
12
|
+
export const DONE = 'DONE';
|
|
13
|
+
let neutralCwd;
|
|
14
|
+
export function isDone(message) {
|
|
15
|
+
if (!message)
|
|
16
|
+
return false;
|
|
17
|
+
// Normalize the message: trim, strip quotes/backticks, strip trailing punctuation, uppercase
|
|
18
|
+
let normalized = message.trim();
|
|
19
|
+
// Strip surrounding quotes or backticks
|
|
20
|
+
if ((normalized.startsWith('"') && normalized.endsWith('"')) ||
|
|
21
|
+
(normalized.startsWith("'") && normalized.endsWith("'")) ||
|
|
22
|
+
(normalized.startsWith('`') && normalized.endsWith('`'))) {
|
|
23
|
+
normalized = normalized.slice(1, -1);
|
|
24
|
+
}
|
|
25
|
+
// Strip trailing punctuation
|
|
26
|
+
normalized = normalized.replace(/[.!,:]+$/, '');
|
|
27
|
+
// Uppercase and check if exactly DONE
|
|
28
|
+
normalized = normalized.toUpperCase().trim();
|
|
29
|
+
// Only true if it is exactly DONE, not a sentence containing the word
|
|
30
|
+
return normalized === DONE;
|
|
31
|
+
}
|
|
32
|
+
export function personaSystemPrompt(c) {
|
|
33
|
+
return [
|
|
34
|
+
c.persona,
|
|
35
|
+
'',
|
|
36
|
+
`What you want: ${c.goal}`,
|
|
37
|
+
'',
|
|
38
|
+
'You are talking to an assistant through a chat box on a web page. Behave like a real person:',
|
|
39
|
+
'- Say what you want in your own words. Do not explain how the assistant should do it.',
|
|
40
|
+
'- If a reply is vague, unhelpful, or does not actually show you the result, say so.',
|
|
41
|
+
'- If you are asked a question, answer it.',
|
|
42
|
+
'- Do not be artificially cooperative, and do not thank the assistant for work it has not done.',
|
|
43
|
+
'',
|
|
44
|
+
'Reply with ONLY the message you would type next — no quotes, no narration, no stage directions.',
|
|
45
|
+
`When you have what you wanted, or you are convinced you will not get it, reply with exactly ${DONE} and nothing else.`
|
|
46
|
+
].join('\n');
|
|
47
|
+
}
|
|
48
|
+
export function personaPrompt(conversation, turnsLeft) {
|
|
49
|
+
if (conversation.length === 0)
|
|
50
|
+
return 'Write your first message to the assistant.';
|
|
51
|
+
const transcript = conversation.map(m => `${m.role === 'user' ? 'you' : 'assistant'}: ${m.text}`).join('\n\n');
|
|
52
|
+
const warning = turnsLeft <= 1
|
|
53
|
+
? '\n\nThis is your last message. If you already have what you needed, reply ' + DONE + '.'
|
|
54
|
+
: '';
|
|
55
|
+
return [
|
|
56
|
+
'The conversation so far:',
|
|
57
|
+
'',
|
|
58
|
+
transcript,
|
|
59
|
+
'',
|
|
60
|
+
`Write your next message, or ${DONE} if you are finished.${warning}`
|
|
61
|
+
].join('\n');
|
|
62
|
+
}
|
|
63
|
+
export async function nextUserMessage(c, conversation, turnsLeft) {
|
|
64
|
+
// Loaded here, not at module top level, so importing the package barrel
|
|
65
|
+
// never requires the Agent SDK — it is an optional peer, and a consumer who
|
|
66
|
+
// only wants the harness primitives must not pay for it. This is the only
|
|
67
|
+
// place in the exported surface that reaches for it at runtime.
|
|
68
|
+
let query;
|
|
69
|
+
try {
|
|
70
|
+
({ query } = await import('@anthropic-ai/claude-agent-sdk'));
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (isMissingSdkError(err))
|
|
74
|
+
throw new Error(MISSING_SDK_MESSAGE);
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
neutralCwd ??= createNeutralCwd();
|
|
78
|
+
let text = '';
|
|
79
|
+
for await (const msg of query({
|
|
80
|
+
prompt: personaPrompt(conversation, turnsLeft),
|
|
81
|
+
options: {
|
|
82
|
+
...isolationOptions(neutralCwd),
|
|
83
|
+
model: process.env.SIM_USER_MODEL ?? 'haiku',
|
|
84
|
+
systemPrompt: personaSystemPrompt(c),
|
|
85
|
+
maxTurns: 1
|
|
86
|
+
}
|
|
87
|
+
})) {
|
|
88
|
+
if (msg.type === 'assistant') {
|
|
89
|
+
for (const block of msg.message?.content ?? []) {
|
|
90
|
+
if (block.type === 'text' && block.text)
|
|
91
|
+
text += block.text;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return text.trim();
|
|
96
|
+
}
|
package/report.d.ts
ADDED
package/report.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the evidence and says what happened. Returns the failure count so the
|
|
3
|
+
* caller sets the exit code — a suite that cannot fail is not a suite.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
// The verdict is written by a model and is untrusted by construction — a
|
|
8
|
+
// well-known slip is a stringified boolean ("false" instead of false), which
|
|
9
|
+
// would otherwise pass truthiness checks and silently report success.
|
|
10
|
+
function isValidVerdict(v, caseName) {
|
|
11
|
+
if (typeof v !== 'object' || v === null)
|
|
12
|
+
return false;
|
|
13
|
+
const o = v;
|
|
14
|
+
return o.case === caseName &&
|
|
15
|
+
typeof o.satisfied === 'boolean' &&
|
|
16
|
+
Array.isArray(o.frictions) &&
|
|
17
|
+
typeof o.summary === 'string';
|
|
18
|
+
}
|
|
19
|
+
export function reportCases(cases, evidenceDir) {
|
|
20
|
+
const read = (file) => {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(path.join(evidenceDir, file), 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
let failures = 0;
|
|
29
|
+
const rows = [['case', 'model', 'turns', 'verdict', 'frictions', 'duration']];
|
|
30
|
+
for (const c of cases) {
|
|
31
|
+
const run = read(`sim-${c.name}.run.json`);
|
|
32
|
+
const verdict = read(`sim-${c.name}.verdict.json`);
|
|
33
|
+
let state;
|
|
34
|
+
let frictions = '-';
|
|
35
|
+
if (!run) {
|
|
36
|
+
state = 'not run';
|
|
37
|
+
failures++;
|
|
38
|
+
}
|
|
39
|
+
else if (!run.valid) {
|
|
40
|
+
state = `invalid (${run.error ?? 'unknown'})`;
|
|
41
|
+
failures++;
|
|
42
|
+
}
|
|
43
|
+
else if (!isValidVerdict(verdict, c.name)) {
|
|
44
|
+
state = 'not judged';
|
|
45
|
+
failures++;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
state = verdict.satisfied === true ? 'satisfied' : 'UNSATISFACTORY';
|
|
49
|
+
if (verdict.satisfied !== true)
|
|
50
|
+
failures++;
|
|
51
|
+
frictions = String(verdict.frictions.length);
|
|
52
|
+
}
|
|
53
|
+
rows.push([c.name, run?.assistantModel ?? '-', String(run?.turns ?? '-'), state, frictions, run ? `${Math.round(run.durationMs / 1000)}s` : '-']);
|
|
54
|
+
}
|
|
55
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map(r => r[i].length)));
|
|
56
|
+
for (const row of rows)
|
|
57
|
+
console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' '));
|
|
58
|
+
for (const c of cases) {
|
|
59
|
+
const verdict = read(`sim-${c.name}.verdict.json`);
|
|
60
|
+
if (!isValidVerdict(verdict, c.name) || verdict.frictions.length === 0)
|
|
61
|
+
continue;
|
|
62
|
+
console.log(`\n${c.name}: ${verdict.summary}`);
|
|
63
|
+
for (const f of verdict.frictions)
|
|
64
|
+
console.log(` - turn ${f.turn}: ${f.what} → ${f.effect}`);
|
|
65
|
+
}
|
|
66
|
+
console.log(failures === 0 ? '\nall cases satisfied' : `\n${failures} case(s) need attention`);
|
|
67
|
+
return failures;
|
|
68
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: simulate
|
|
3
|
+
description: Run the scenario simulations - drive real browser conversations with a simulated user, then dispatch a judge per transcript. Use when asked to run the simulations, or after changing a system prompt, a tool description, or the chat orchestration.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Running the scenario simulations
|
|
7
|
+
|
|
8
|
+
Unit and e2e tests answer "does this mechanism work". This answers "did a person
|
|
9
|
+
get what they came for", by having a simulated one try and then judging the
|
|
10
|
+
transcript.
|
|
11
|
+
|
|
12
|
+
## Before you start
|
|
13
|
+
|
|
14
|
+
Three things must be true, and each fails confusingly if it is not:
|
|
15
|
+
|
|
16
|
+
1. The dev stack is up — `bash dev/status.sh`.
|
|
17
|
+
2. The workspace packages are built — `ls lib-vue/*.js lib-vuetify/*.js`. If they
|
|
18
|
+
are missing, e2e-style runs fail with "element not found".
|
|
19
|
+
3. The bridge is running — `npm run dev-bridge`. The runner checks this and says so.
|
|
20
|
+
|
|
21
|
+
Ask the user to start anything that is down. Never start or stop dev processes yourself.
|
|
22
|
+
|
|
23
|
+
## Steps
|
|
24
|
+
|
|
25
|
+
1. **Read the case list** in `simulations/cases/index.ts`. Note each `name` and `goal`.
|
|
26
|
+
|
|
27
|
+
2. **Delete evidence from earlier runs.**
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
rm -f simulations/tmp/sim-*
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Evidence persists and is only rewritten by a case that actually runs. Without
|
|
34
|
+
this, a case that fails to dispatch reports the previous run's verdict as
|
|
35
|
+
though it were this one's. Deleting first turns that into a visible `not run`.
|
|
36
|
+
|
|
37
|
+
3. **Run the cases.**
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm run simulate # every case
|
|
41
|
+
SIM_CASES=air-quality npm run simulate # one case
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Models are pinned by `SIM_ASSISTANT_MODEL` (default `sonnet`) and
|
|
45
|
+
`SIM_USER_MODEL` (default `haiku`), and recorded per run, so verdicts from
|
|
46
|
+
different tiers are never compared silently.
|
|
47
|
+
|
|
48
|
+
4. **Ignore the runner's own account of how it went.** The transcript at
|
|
49
|
+
`simulations/tmp/sim-<case>.json` is the evidence. A Playwright `passed` line
|
|
50
|
+
means the run was valid, not that the product behaved.
|
|
51
|
+
|
|
52
|
+
A case whose sidecar says `valid: false` must NOT be judged — read
|
|
53
|
+
`simulations/tmp/sim-<case>.run.json` for the recorded error instead.
|
|
54
|
+
|
|
55
|
+
5. **Dispatch one `simulation-judge` subagent per valid case.** Give it paths, not
|
|
56
|
+
pasted content — transcripts carry every gateway request:
|
|
57
|
+
|
|
58
|
+
- the case name and its goal
|
|
59
|
+
- the transcript path, `simulations/tmp/sim-<case>.json`
|
|
60
|
+
- ask for the JSON verdict its own definition specifies
|
|
61
|
+
|
|
62
|
+
6. **Write each verdict** to `simulations/tmp/sim-<case>.verdict.json` as raw JSON.
|
|
63
|
+
Strip any code fence the judge added. A malformed verdict reports as
|
|
64
|
+
`not judged`, which is deliberate — check the file rather than being surprised.
|
|
65
|
+
|
|
66
|
+
7. **Report.**
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm run simulate:report
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Relay the summary and the friction list. Exit code is non-zero if any case was
|
|
73
|
+
unsatisfactory, invalid, not judged, or never ran.
|
|
74
|
+
|
|
75
|
+
## Reading the result
|
|
76
|
+
|
|
77
|
+
The friction list is the point. "Unsatisfactory" tells you a run went badly; a
|
|
78
|
+
friction point names the reply or tool result that misled the person and what they
|
|
79
|
+
did next — that is what turns a run into a concrete change.
|
|
80
|
+
|
|
81
|
+
Rate limits are the practical ceiling: three Claude roles per case on one
|
|
82
|
+
subscription. A run cut short by a rate limit is an **invalid run**, not a product
|
|
83
|
+
failure — check the sidecar before concluding anything.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: simulation-judge
|
|
3
|
+
description: Judge one scenario simulation transcript and return a JSON verdict. Use when asked to verdict a simulation run produced by the /simulate skill.
|
|
4
|
+
tools: Read
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You are judging whether a chat assistant actually served a person, from the
|
|
8
|
+
transcript of one simulated conversation.
|
|
9
|
+
|
|
10
|
+
You are given a case name, the person's goal, and a path to a transcript. Read
|
|
11
|
+
the transcript with `Read`; do not ask for it to be pasted.
|
|
12
|
+
|
|
13
|
+
The transcript holds:
|
|
14
|
+
- `conversation` — what the person and the assistant said, as rendered on screen
|
|
15
|
+
- `gateway` — every request the page made, carrying the tools it offered and the
|
|
16
|
+
tool calls the assistant actually made
|
|
17
|
+
- `consoleErrors` — browser errors during the run
|
|
18
|
+
|
|
19
|
+
`gateway` records what the browser SENT to the server, and each request carries
|
|
20
|
+
the whole conversation so far — so the assistant's FINAL reply of a conversation
|
|
21
|
+
never appears there, because no later request resends it. Read the final
|
|
22
|
+
assistant turn from `conversation`, not `gateway`, and never conclude "the
|
|
23
|
+
assistant never answered" from its absence in `gateway`.
|
|
24
|
+
|
|
25
|
+
`gateway[].toolCalls` is CUMULATIVE: exchange N contains every tool call from
|
|
26
|
+
turns 1..N, not just that turn's. Do not treat this as the same tool call being
|
|
27
|
+
repeated — compare call counts across exchanges before filing repetition as a
|
|
28
|
+
friction point, or you will report calls that never actually recurred.
|
|
29
|
+
|
|
30
|
+
Judge the run against the goal, not against your idea of a good answer. The
|
|
31
|
+
person is not a tester: if they had to ask three times, that is a finding even
|
|
32
|
+
if the final answer was correct.
|
|
33
|
+
|
|
34
|
+
**The friction list is the point.** A score says a run went badly; a friction
|
|
35
|
+
point says which reply or tool result misled the person and what they concluded.
|
|
36
|
+
That is what turns a run into a concrete change to a prompt or a tool
|
|
37
|
+
description. Look especially for:
|
|
38
|
+
- the assistant claiming it did something the `gateway` record shows it never did
|
|
39
|
+
- a tool offered but never used when it was obviously needed, or called with
|
|
40
|
+
arguments that misread the person's words
|
|
41
|
+
- the same tool called repeatedly with no progress
|
|
42
|
+
- the person having to supply information the assistant could have looked up
|
|
43
|
+
- an answer that is correct but never shown where the person asked for it
|
|
44
|
+
|
|
45
|
+
Return ONLY raw JSON, no code fence, in exactly this shape:
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
"case": "<case name>",
|
|
49
|
+
"satisfied": true | false,
|
|
50
|
+
"summary": "<one sentence: did the person get what they came for>",
|
|
51
|
+
"frictions": [
|
|
52
|
+
{ "turn": <number>, "what": "<what the assistant or a tool did>", "effect": "<what the person concluded or had to do>" }
|
|
53
|
+
],
|
|
54
|
+
"notes": "<anything a maintainer should know, or empty>"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
`satisfied` is true only if the person's goal was actually met and visibly so.
|
|
58
|
+
An empty `frictions` array is a real answer when a run went cleanly.
|
|
59
|
+
|
|
60
|
+
A friction's `turn` is the 1-based index of the USER turn it occurred on — the
|
|
61
|
+
Nth message the person sent, counting only the person's messages in
|
|
62
|
+
`conversation` and not the assistant's. So a friction caused by the
|
|
63
|
+
assistant's reply to the person's 3rd message is still `"turn": 3`. Count
|
|
64
|
+
consistently this way so that two judges reading the same transcript would
|
|
65
|
+
agree on the number.
|
package/transcript.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Transcript, RunSidecar } from './types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Default evidence location, resolved against the host repo's cwd. Kept as the
|
|
4
|
+
* default rather than baked in: `reportCases(cases, evidenceDir)` already lets
|
|
5
|
+
* the host choose where evidence lives, so writing had to be able to follow it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const evidenceDir: string;
|
|
8
|
+
export type { Transcript, RunSidecar };
|
|
9
|
+
export declare function writeEvidence(name: string, transcript: Transcript, sidecar: RunSidecar, dir?: string): void;
|
package/transcript.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence files, split in two on purpose.
|
|
3
|
+
*
|
|
4
|
+
* The transcript is what the judge reads. The sidecar records whether the run
|
|
5
|
+
* was VALID — a case that fails to dispatch must report `not run` rather than
|
|
6
|
+
* silently re-reporting the previous run's verdict, which is why both are
|
|
7
|
+
* deleted before a suite and only rewritten by a case that actually executes.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
/**
|
|
12
|
+
* Default evidence location, resolved against the host repo's cwd. Kept as the
|
|
13
|
+
* default rather than baked in: `reportCases(cases, evidenceDir)` already lets
|
|
14
|
+
* the host choose where evidence lives, so writing had to be able to follow it.
|
|
15
|
+
*/
|
|
16
|
+
export const evidenceDir = path.join(process.cwd(), 'simulations', 'tmp');
|
|
17
|
+
export function writeEvidence(name, transcript, sidecar, dir = evidenceDir) {
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
fs.writeFileSync(path.join(dir, `sim-${name}.json`), JSON.stringify(transcript, null, 2));
|
|
20
|
+
fs.writeFileSync(path.join(dir, `sim-${name}.run.json`), JSON.stringify(sidecar, null, 2));
|
|
21
|
+
}
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes a host repo needs to write cases and read evidence.
|
|
3
|
+
*/
|
|
4
|
+
import type { GatewayExchange } from './gateway-capture.ts';
|
|
5
|
+
export type SimulationCase = {
|
|
6
|
+
/** Evidence files are named after this; keep it filesystem-safe. */
|
|
7
|
+
name: string;
|
|
8
|
+
route: string;
|
|
9
|
+
/** Who the simulated user is. Becomes its system prompt. */
|
|
10
|
+
persona: string;
|
|
11
|
+
/** What they came for, in their own words. */
|
|
12
|
+
goal: string;
|
|
13
|
+
/** Give up after this many user turns; the judge sees how far it got. */
|
|
14
|
+
maxTurns: number;
|
|
15
|
+
};
|
|
16
|
+
export type Transcript = {
|
|
17
|
+
case: string;
|
|
18
|
+
goal: string;
|
|
19
|
+
persona: string;
|
|
20
|
+
route: string;
|
|
21
|
+
conversation: Array<{
|
|
22
|
+
role: string;
|
|
23
|
+
text: string;
|
|
24
|
+
}>;
|
|
25
|
+
gateway: GatewayExchange[];
|
|
26
|
+
consoleErrors: string[];
|
|
27
|
+
};
|
|
28
|
+
export type RunSidecar = {
|
|
29
|
+
case: string;
|
|
30
|
+
valid: boolean;
|
|
31
|
+
error?: string;
|
|
32
|
+
assistantModel: string;
|
|
33
|
+
userModel: string;
|
|
34
|
+
turns: number;
|
|
35
|
+
durationMs: number;
|
|
36
|
+
finishedAt: string;
|
|
37
|
+
};
|
package/types.js
ADDED