@data-fair/lib-agents-sim 0.4.0 → 0.5.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/gateway-capture.d.ts +48 -0
- package/gateway-capture.js +54 -0
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/metrics.d.ts +52 -0
- package/metrics.js +95 -0
- package/package.json +1 -1
- package/page-perception.d.ts +9 -0
- package/page-perception.js +41 -8
- package/persona.d.ts +21 -1
- package/persona.js +63 -12
- package/report.js +37 -2
- package/templates/agents-sim-skill.md +70 -8
- package/templates/simulation-judge.md +125 -43
- package/transcript.js +6 -1
- package/types.d.ts +7 -0
package/gateway-capture.d.ts
CHANGED
|
@@ -22,10 +22,58 @@ export type GatewayExchange = {
|
|
|
22
22
|
name: string;
|
|
23
23
|
arguments: string;
|
|
24
24
|
}>;
|
|
25
|
+
/** Characters the application injected as `<host-state>` / `<host-events>` /
|
|
26
|
+
* `<hidden-context>` blocks, across every message of this request. They land in
|
|
27
|
+
* tool results and hidden context, neither of which survives into this summary,
|
|
28
|
+
* so the size of what the host reports can only be measured here. Cumulative
|
|
29
|
+
* like the history, so the last request of a conversation carries its total.
|
|
30
|
+
*/
|
|
31
|
+
hostBlockChars: number;
|
|
32
|
+
/** What each tool ANSWERED, paired with the call that asked. Cumulative like
|
|
33
|
+
* `toolCalls`. Without these a judge sees only what the assistant asked a tool
|
|
34
|
+
* and can never check a claim about the answer against it — "the form data is
|
|
35
|
+
* valid and saved" was unfalsifiable in a recorded run for exactly this reason.
|
|
36
|
+
* Each result is capped: one large payload must not swallow the evidence file.
|
|
37
|
+
*/
|
|
38
|
+
toolResults: Array<{
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
result: string;
|
|
42
|
+
}>;
|
|
25
43
|
/** When true, this exchange was received but postData could not be parsed. Evidence of
|
|
26
44
|
* a failed capture rather than an absent request.
|
|
27
45
|
*/
|
|
28
46
|
unparsed?: true;
|
|
29
47
|
};
|
|
48
|
+
export declare const TOOL_RESULT_MAX_CHARS = 2000;
|
|
49
|
+
/** Only closed blocks count: an unterminated open tag would otherwise swallow
|
|
50
|
+
* the whole rest of the message and report it as host overhead. */
|
|
51
|
+
export declare function countHostBlockChars(messages: unknown[]): number;
|
|
52
|
+
type Body = {
|
|
53
|
+
model?: string;
|
|
54
|
+
messages?: Array<{
|
|
55
|
+
role?: string;
|
|
56
|
+
content?: unknown;
|
|
57
|
+
tool_call_id?: string;
|
|
58
|
+
tool_calls?: Array<{
|
|
59
|
+
id?: string;
|
|
60
|
+
function?: {
|
|
61
|
+
name?: string;
|
|
62
|
+
arguments?: string;
|
|
63
|
+
};
|
|
64
|
+
}>;
|
|
65
|
+
}>;
|
|
66
|
+
tools?: Array<{
|
|
67
|
+
function?: {
|
|
68
|
+
name?: string;
|
|
69
|
+
};
|
|
70
|
+
}>;
|
|
71
|
+
};
|
|
72
|
+
export declare function extractToolResults(messages: NonNullable<Body['messages']>): {
|
|
73
|
+
id: string;
|
|
74
|
+
name: string;
|
|
75
|
+
result: string;
|
|
76
|
+
}[];
|
|
30
77
|
export declare function summariseRequest(body: unknown): GatewayExchange | null;
|
|
31
78
|
export declare function captureGateway(page: Page): GatewayExchange[];
|
|
79
|
+
export {};
|
package/gateway-capture.js
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
+
export const TOOL_RESULT_MAX_CHARS = 2000;
|
|
2
|
+
const HOST_BLOCKS = ['host-state', 'host-events', 'hidden-context'];
|
|
3
|
+
/** Only closed blocks count: an unterminated open tag would otherwise swallow
|
|
4
|
+
* the whole rest of the message and report it as host overhead. */
|
|
5
|
+
export function countHostBlockChars(messages) {
|
|
6
|
+
let total = 0;
|
|
7
|
+
for (const message of messages) {
|
|
8
|
+
const content = message?.content;
|
|
9
|
+
const text = typeof content === 'string' ? content : JSON.stringify(content ?? '');
|
|
10
|
+
for (const name of HOST_BLOCKS) {
|
|
11
|
+
const open = `<${name}>`;
|
|
12
|
+
const close = `</${name}>`;
|
|
13
|
+
let from = 0;
|
|
14
|
+
for (;;) {
|
|
15
|
+
const start = text.indexOf(open, from);
|
|
16
|
+
if (start === -1)
|
|
17
|
+
break;
|
|
18
|
+
const end = text.indexOf(close, start);
|
|
19
|
+
if (end === -1)
|
|
20
|
+
break;
|
|
21
|
+
total += end + close.length - start;
|
|
22
|
+
from = end + close.length;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return total;
|
|
27
|
+
}
|
|
28
|
+
function renderToolResult(content) {
|
|
29
|
+
const text = typeof content === 'string' ? content : JSON.stringify(content ?? '');
|
|
30
|
+
return text.length <= TOOL_RESULT_MAX_CHARS ? text : text.slice(0, TOOL_RESULT_MAX_CHARS) + '…[truncated]';
|
|
31
|
+
}
|
|
32
|
+
export function extractToolResults(messages) {
|
|
33
|
+
// The call that asked may live in any earlier message, so the name is looked
|
|
34
|
+
// up by id across the whole history rather than by adjacency.
|
|
35
|
+
const names = new Map();
|
|
36
|
+
for (const m of messages) {
|
|
37
|
+
for (const call of m.tool_calls ?? []) {
|
|
38
|
+
if (call.id)
|
|
39
|
+
names.set(call.id, call.function?.name ?? '');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return messages
|
|
43
|
+
.filter(m => m.role === 'tool')
|
|
44
|
+
.map(m => ({
|
|
45
|
+
id: m.tool_call_id ?? '',
|
|
46
|
+
// An unpaired result is still evidence; recording it nameless beats dropping it.
|
|
47
|
+
name: names.get(m.tool_call_id ?? '') ?? '',
|
|
48
|
+
result: renderToolResult(m.content)
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
1
51
|
function extractUserMessageText(content) {
|
|
2
52
|
if (typeof content === 'string')
|
|
3
53
|
return content;
|
|
@@ -29,6 +79,8 @@ export function summariseRequest(body) {
|
|
|
29
79
|
toolNames: (b.tools ?? []).map(t => t.function?.name ?? '').filter(Boolean),
|
|
30
80
|
messageCount: b.messages.length,
|
|
31
81
|
lastUserMessage: extractUserMessageText(last?.content),
|
|
82
|
+
hostBlockChars: countHostBlockChars(b.messages),
|
|
83
|
+
toolResults: extractToolResults(b.messages),
|
|
32
84
|
toolCalls: b.messages.flatMap(m => (m.tool_calls ?? []).map(c => ({
|
|
33
85
|
name: c.function?.name ?? '',
|
|
34
86
|
arguments: c.function?.arguments ?? ''
|
|
@@ -52,6 +104,8 @@ export function captureGateway(page) {
|
|
|
52
104
|
messageCount: 0,
|
|
53
105
|
lastUserMessage: '',
|
|
54
106
|
toolCalls: [],
|
|
107
|
+
hostBlockChars: 0,
|
|
108
|
+
toolResults: [],
|
|
55
109
|
unparsed: true
|
|
56
110
|
});
|
|
57
111
|
return;
|
package/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export type { SimulationCase, Transcript, RunSidecar } from './types.ts';
|
|
2
2
|
export { createNeutralCwd, scrubEnv, isolationOptions, type Env } from './isolation.ts';
|
|
3
3
|
export { captureGateway, summariseRequest, type GatewayExchange } from './gateway-capture.ts';
|
|
4
|
-
export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS } from './persona.ts';
|
|
4
|
+
export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS, DEFAULT_USER_MODEL, resolveUserModel } from './persona.ts';
|
|
5
5
|
export { writeEvidence, evidenceDir } from './transcript.ts';
|
|
6
|
+
export { computeMetrics, type RunMetrics } from './metrics.ts';
|
|
6
7
|
export { selectCases } from './cases.ts';
|
|
7
8
|
export { reportCases } from './report.ts';
|
|
8
9
|
export { createChatDriver, chatDriverStrings, type ChatRoot, type ChatDriverLocale, TURN_TIMEOUT_MS, SEND_TIMEOUT_MS } from './chat-driver.ts';
|
package/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { createNeutralCwd, scrubEnv, isolationOptions } from "./isolation.js";
|
|
2
2
|
export { captureGateway, summariseRequest } from "./gateway-capture.js";
|
|
3
|
-
export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS } from "./persona.js";
|
|
3
|
+
export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS, DEFAULT_USER_MODEL, resolveUserModel } from "./persona.js";
|
|
4
4
|
export { writeEvidence, evidenceDir } from "./transcript.js";
|
|
5
|
+
export { computeMetrics } from "./metrics.js";
|
|
5
6
|
export { selectCases } from "./cases.js";
|
|
6
7
|
export { reportCases } from "./report.js";
|
|
7
8
|
export { createChatDriver, chatDriverStrings, TURN_TIMEOUT_MS, SEND_TIMEOUT_MS } from "./chat-driver.js";
|
package/metrics.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facts derived from a recorded run, handed to the judge as evidence.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here scores anything. There is no threshold, no enum, no pass mark —
|
|
5
|
+
* a metric that decided whether a run was good would narrow the judge's
|
|
6
|
+
* attention to the things that happen to be countable, which is the opposite of
|
|
7
|
+
* what the judge is for. These exist because a few quantities are tedious and
|
|
8
|
+
* error-prone to count by reading a transcript, and two of them are outright
|
|
9
|
+
* traps: `toolCalls` is cumulative, and a sub-agent's requests are interleaved
|
|
10
|
+
* with the lead's.
|
|
11
|
+
*/
|
|
12
|
+
import type { Transcript } from './types.ts';
|
|
13
|
+
export type RunMetrics = {
|
|
14
|
+
/** Messages the person actually sent. */
|
|
15
|
+
userMessages: number;
|
|
16
|
+
assistantBubbles: number;
|
|
17
|
+
/**
|
|
18
|
+
* Bubbles that rendered no prose. Almost always a tool-call bubble, which DOES
|
|
19
|
+
* render a chip naming the tool on screen — so this is not a count of silence,
|
|
20
|
+
* and three judges in a row misread it as one. What it measures is how much of
|
|
21
|
+
* a run the person watched as tool names rather than sentences.
|
|
22
|
+
*/
|
|
23
|
+
textlessAssistantBubbles: number;
|
|
24
|
+
avgVisibleReplyChars: number | null;
|
|
25
|
+
/** Requests to the gateway, every conversation included. */
|
|
26
|
+
modelRequests: number;
|
|
27
|
+
/** The role that served the person's own conversation (the first request's). */
|
|
28
|
+
leadModel: string;
|
|
29
|
+
leadRequests: number;
|
|
30
|
+
nonLeadRequests: number;
|
|
31
|
+
/**
|
|
32
|
+
* Requests per model role, when the record names one. This is the authority:
|
|
33
|
+
* inferring roles from message counts reported three `summarizer` compaction
|
|
34
|
+
* calls as sub-agent dispatches in a run that made no sub-agent call at all.
|
|
35
|
+
* Null when no request carries a role, where the interleaving split is all
|
|
36
|
+
* there is.
|
|
37
|
+
*/
|
|
38
|
+
requestsByModel: Record<string, number> | null;
|
|
39
|
+
/** `modelRequests / userMessages`, to one decimal; null when nobody spoke. */
|
|
40
|
+
requestsPerUserMessage: number | null;
|
|
41
|
+
/** The biggest prompt handed to a role other than the lead's, and which role
|
|
42
|
+
* took it; null when every request was the lead's. A summarizer legitimately
|
|
43
|
+
* carries the conversation, so read the role before reading the size. */
|
|
44
|
+
largestNonLeadPromptChars: number | null;
|
|
45
|
+
largestNonLeadPromptModel: string | null;
|
|
46
|
+
/** Tool calls issued twice with identical arguments, across every conversation. */
|
|
47
|
+
duplicateToolCalls: number;
|
|
48
|
+
/** Characters the host injected as `<host-state>` / `<host-events>` blocks;
|
|
49
|
+
* null for a run recorded before the capture measured them. */
|
|
50
|
+
hostBlockChars: number | null;
|
|
51
|
+
};
|
|
52
|
+
export declare function computeMetrics(transcript: Transcript): RunMetrics;
|
package/metrics.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const signature = (e) => JSON.stringify([...e.toolNames].sort());
|
|
2
|
+
/**
|
|
3
|
+
* Split the gateway record back into the conversations that produced it.
|
|
4
|
+
*
|
|
5
|
+
* One conversation's `messageCount` only ever grows; a sub-agent starts a fresh
|
|
6
|
+
* one at 2 while the lead is already deep, and the lead then resumes. The tool
|
|
7
|
+
* set is a hint rather than the rule, because page tools come and go with the
|
|
8
|
+
* route — a recorded lead went 14 to 19 to 26 tools without ever restarting.
|
|
9
|
+
*/
|
|
10
|
+
function splitConversations(gateway) {
|
|
11
|
+
// `streams` is kept in most-recently-used order, so `opened` is what restores
|
|
12
|
+
// the order the conversations actually started in — which is what makes the
|
|
13
|
+
// first one the lead's.
|
|
14
|
+
const streams = [];
|
|
15
|
+
for (const exchange of gateway) {
|
|
16
|
+
// Most recently used first: when the tool set gives no answer, the lead is
|
|
17
|
+
// the stream that spoke last.
|
|
18
|
+
const open = streams.filter(s => s.last < exchange.messageCount).reverse();
|
|
19
|
+
const target = open.find(s => s.tools === signature(exchange)) ?? open[0];
|
|
20
|
+
if (target) {
|
|
21
|
+
target.exchanges.push(exchange);
|
|
22
|
+
target.last = exchange.messageCount;
|
|
23
|
+
target.tools = signature(exchange);
|
|
24
|
+
// Move to the back, so "most recently used" stays true.
|
|
25
|
+
streams.splice(streams.indexOf(target), 1);
|
|
26
|
+
streams.push(target);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
streams.push({ exchanges: [exchange], last: exchange.messageCount, tools: signature(exchange), opened: streams.length });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return [...streams].sort((a, b) => a.opened - b.opened).map(s => s.exchanges);
|
|
33
|
+
}
|
|
34
|
+
/** Within one conversation the tool-call list is cumulative, so the longest is the whole of it. */
|
|
35
|
+
function finalToolCalls(stream) {
|
|
36
|
+
let longest = [];
|
|
37
|
+
for (const e of stream)
|
|
38
|
+
if (e.toolCalls.length > longest.length)
|
|
39
|
+
longest = e.toolCalls;
|
|
40
|
+
return longest;
|
|
41
|
+
}
|
|
42
|
+
function countDuplicates(calls) {
|
|
43
|
+
const seen = new Set(calls.map(c => JSON.stringify([c.name, c.arguments])));
|
|
44
|
+
return calls.length - seen.size;
|
|
45
|
+
}
|
|
46
|
+
export function computeMetrics(transcript) {
|
|
47
|
+
const userMessages = transcript.conversation.filter(m => m.role === 'user').length;
|
|
48
|
+
const assistant = transcript.conversation.filter(m => m.role === 'assistant');
|
|
49
|
+
const visible = assistant.filter(m => m.text.trim().length > 0);
|
|
50
|
+
// Prefer what the record says over what the shape implies.
|
|
51
|
+
const roled = transcript.gateway.filter(e => e.model);
|
|
52
|
+
const leadModel = transcript.gateway[0]?.model ?? '';
|
|
53
|
+
const requestsByModel = roled.length
|
|
54
|
+
? roled.reduce((acc, e) => { acc[e.model] = (acc[e.model] ?? 0) + 1; return acc; }, {})
|
|
55
|
+
: null;
|
|
56
|
+
const conversations = splitConversations(transcript.gateway);
|
|
57
|
+
let lead;
|
|
58
|
+
let nonLead;
|
|
59
|
+
if (requestsByModel && leadModel) {
|
|
60
|
+
lead = transcript.gateway.filter(e => e.model === leadModel);
|
|
61
|
+
nonLead = transcript.gateway.filter(e => e.model !== leadModel);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// No role in the record: the first request answers the person's first
|
|
65
|
+
// message, so the conversation it opened is the lead's.
|
|
66
|
+
const [first = [], ...rest] = conversations;
|
|
67
|
+
lead = first;
|
|
68
|
+
nonLead = rest.flat();
|
|
69
|
+
}
|
|
70
|
+
const biggest = nonLead.reduce((best, e) => (!best || e.lastUserMessage.length > best.lastUserMessage.length ? e : best), null);
|
|
71
|
+
const measuredHostBlocks = transcript.gateway.some(e => typeof e.hostBlockChars === 'number');
|
|
72
|
+
return {
|
|
73
|
+
userMessages,
|
|
74
|
+
assistantBubbles: assistant.length,
|
|
75
|
+
textlessAssistantBubbles: assistant.length - visible.length,
|
|
76
|
+
avgVisibleReplyChars: visible.length
|
|
77
|
+
? Math.round(visible.reduce((sum, m) => sum + m.text.trim().length, 0) / visible.length)
|
|
78
|
+
: null,
|
|
79
|
+
modelRequests: transcript.gateway.length,
|
|
80
|
+
leadModel,
|
|
81
|
+
leadRequests: lead.length,
|
|
82
|
+
nonLeadRequests: nonLead.length,
|
|
83
|
+
requestsByModel,
|
|
84
|
+
requestsPerUserMessage: userMessages
|
|
85
|
+
? Math.round((transcript.gateway.length / userMessages) * 10) / 10
|
|
86
|
+
: null,
|
|
87
|
+
largestNonLeadPromptChars: biggest ? biggest.lastUserMessage.length : null,
|
|
88
|
+
largestNonLeadPromptModel: biggest ? (biggest.model || null) : null,
|
|
89
|
+
duplicateToolCalls: conversations.reduce((sum, s) => sum + countDuplicates(finalToolCalls(s)), 0),
|
|
90
|
+
hostBlockChars: measuredHostBlocks
|
|
91
|
+
// Cumulative like the history, so a conversation's last request holds its total.
|
|
92
|
+
? conversations.reduce((sum, s) => sum + Math.max(0, ...s.map(e => e.hostBlockChars ?? 0)), 0)
|
|
93
|
+
: null
|
|
94
|
+
};
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@data-fair/lib-agents-sim",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
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
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
package/page-perception.d.ts
CHANGED
|
@@ -31,6 +31,15 @@ export type PagePerception = {
|
|
|
31
31
|
*/
|
|
32
32
|
offLimits: string[];
|
|
33
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* Head AND tail, because overlays live at the end.
|
|
36
|
+
*
|
|
37
|
+
* A dialog is teleported to the end of the DOM, so a head-only cut removes
|
|
38
|
+
* precisely what a person is being asked to look at. A judged run turned on it:
|
|
39
|
+
* the persona clicked "Ajouter une colonne", its snapshot was cut at the same
|
|
40
|
+
* point before and after the click, and whether the dialog ever opened was not
|
|
41
|
+
* decidable from the record — the judge had to say so instead of ruling.
|
|
42
|
+
*/
|
|
34
43
|
export declare function truncate(text: string): string;
|
|
35
44
|
export declare function createPagePerception(roots: PerceptionRoot[], opts?: {
|
|
36
45
|
offLimits?: string[];
|
package/page-perception.js
CHANGED
|
@@ -34,8 +34,22 @@ export const SNAPSHOT_CAP = 4000;
|
|
|
34
34
|
// must surface through the existing try/catch as a recorded observation
|
|
35
35
|
// instead of an unrecorded hang.
|
|
36
36
|
export const ACTION_TIMEOUT_MS = 15000;
|
|
37
|
+
/**
|
|
38
|
+
* Head AND tail, because overlays live at the end.
|
|
39
|
+
*
|
|
40
|
+
* A dialog is teleported to the end of the DOM, so a head-only cut removes
|
|
41
|
+
* precisely what a person is being asked to look at. A judged run turned on it:
|
|
42
|
+
* the persona clicked "Ajouter une colonne", its snapshot was cut at the same
|
|
43
|
+
* point before and after the click, and whether the dialog ever opened was not
|
|
44
|
+
* decidable from the record — the judge had to say so instead of ruling.
|
|
45
|
+
*/
|
|
37
46
|
export function truncate(text) {
|
|
38
|
-
|
|
47
|
+
if (text.length <= SNAPSHOT_CAP)
|
|
48
|
+
return text;
|
|
49
|
+
const marker = '\n…[truncated]\n';
|
|
50
|
+
const head = Math.floor(SNAPSHOT_CAP * 0.6);
|
|
51
|
+
const tail = SNAPSHOT_CAP - head;
|
|
52
|
+
return text.slice(0, head) + marker + text.slice(-tail);
|
|
39
53
|
}
|
|
40
54
|
const TOOLS = [
|
|
41
55
|
{
|
|
@@ -93,19 +107,38 @@ export function createPagePerception(roots, opts = {}) {
|
|
|
93
107
|
}
|
|
94
108
|
return null;
|
|
95
109
|
};
|
|
110
|
+
/** As above, but says WHICH finder matched — `click` reports it. */
|
|
111
|
+
const firstMatchKind = async (finders) => {
|
|
112
|
+
for (const [kind, find] of finders) {
|
|
113
|
+
try {
|
|
114
|
+
const loc = find();
|
|
115
|
+
if (await loc.count() > 0)
|
|
116
|
+
return { kind, loc };
|
|
117
|
+
}
|
|
118
|
+
catch { /* a finder that throws simply does not match */ }
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
};
|
|
96
122
|
const click = async (name) => {
|
|
97
123
|
if (isOffLimits(name))
|
|
98
124
|
return OFF_LIMITS_RESULT;
|
|
99
125
|
for (const { root } of roots) {
|
|
100
|
-
const
|
|
101
|
-
() => root.getByRole('button', { name }).first(),
|
|
102
|
-
() => root.getByRole('link', { name }).first(),
|
|
103
|
-
() => root.getByText(name).first()
|
|
126
|
+
const match = await firstMatchKind([
|
|
127
|
+
['control', () => root.getByRole('button', { name }).first()],
|
|
128
|
+
['control', () => root.getByRole('link', { name }).first()],
|
|
129
|
+
['text', () => root.getByText(name).first()]
|
|
104
130
|
]);
|
|
105
|
-
if (
|
|
131
|
+
if (match) {
|
|
106
132
|
try {
|
|
107
|
-
await loc.click({ timeout: ACTION_TIMEOUT_MS });
|
|
108
|
-
|
|
133
|
+
await match.loc.click({ timeout: ACTION_TIMEOUT_MS });
|
|
134
|
+
// Playwright clicks whatever is visible, so the text fallback succeeds
|
|
135
|
+
// on a paragraph as readily as on a button. Saying which one it was is
|
|
136
|
+
// the difference between a person learning nothing happened and a
|
|
137
|
+
// person concluding the product is broken — a recorded run did exactly
|
|
138
|
+
// that, and the judge filed it as a product failure.
|
|
139
|
+
return match.kind === 'control'
|
|
140
|
+
? `clicked "${name}"`
|
|
141
|
+
: `clicked the text "${name}", which is not a button or a link — nothing may happen`;
|
|
109
142
|
}
|
|
110
143
|
catch (err) {
|
|
111
144
|
return `could not click "${name}": ${err instanceof Error ? err.message : String(err)}`;
|
package/persona.d.ts
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import type { SimulationCase } from './types.ts';
|
|
2
2
|
import type { PagePerception } from './page-perception.ts';
|
|
3
3
|
export declare const DONE = "DONE";
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The model the simulated person runs on unless the caller overrides it.
|
|
6
|
+
*
|
|
7
|
+
* Exported because hosts record which model ran, in a sidecar whose whole point
|
|
8
|
+
* is that verdicts from different tiers are never compared silently — and a host
|
|
9
|
+
* that re-derived this with its own literal recorded `haiku` for a run that
|
|
10
|
+
* actually used `sonnet`, the moment this default changed. One value, read by
|
|
11
|
+
* both the runner and the recorder.
|
|
12
|
+
*
|
|
13
|
+
* Sonnet, not haiku: on haiku the persona stopped enforcing its own goal —
|
|
14
|
+
* accepting a chat-only answer for a goal that demanded the result on screen,
|
|
15
|
+
* and asserting it could see nothing but the chat while its own look had just
|
|
16
|
+
* returned the page. A persona that lets the product off the hook produces green
|
|
17
|
+
* runs that prove nothing.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DEFAULT_USER_MODEL = "sonnet";
|
|
20
|
+
/** What `nextUserMessage` will actually run on, given an environment. */
|
|
21
|
+
export declare function resolveUserModel(env?: {
|
|
22
|
+
SIM_USER_MODEL?: string;
|
|
23
|
+
}): string;
|
|
24
|
+
export declare const PERSONA_MAX_TURNS = 25;
|
|
5
25
|
export declare const PERCEPTION_INSTRUCTIONS = "You can look at the screen yourself with the look tool, and you can click and type\non the page. Before you say anything about what is or is not on the screen, look.\nNever claim you cannot see something you have not looked for.";
|
|
6
26
|
export declare function isDone(message: string): boolean;
|
|
7
27
|
export declare function personaSystemPrompt(c: SimulationCase, perceptionEnabled?: boolean, offLimitsActive?: boolean): string;
|
package/persona.js
CHANGED
|
@@ -11,12 +11,49 @@ import { createNeutralCwd, isolationOptions } from "./isolation.js";
|
|
|
11
11
|
import { MISSING_SDK_MESSAGE, isMissingSdkError } from "./missing-sdk.js";
|
|
12
12
|
import { MCP_SERVER_NAME } from "./page-perception.js";
|
|
13
13
|
export const DONE = 'DONE';
|
|
14
|
+
/**
|
|
15
|
+
* The model the simulated person runs on unless the caller overrides it.
|
|
16
|
+
*
|
|
17
|
+
* Exported because hosts record which model ran, in a sidecar whose whole point
|
|
18
|
+
* is that verdicts from different tiers are never compared silently — and a host
|
|
19
|
+
* that re-derived this with its own literal recorded `haiku` for a run that
|
|
20
|
+
* actually used `sonnet`, the moment this default changed. One value, read by
|
|
21
|
+
* both the runner and the recorder.
|
|
22
|
+
*
|
|
23
|
+
* Sonnet, not haiku: on haiku the persona stopped enforcing its own goal —
|
|
24
|
+
* accepting a chat-only answer for a goal that demanded the result on screen,
|
|
25
|
+
* and asserting it could see nothing but the chat while its own look had just
|
|
26
|
+
* returned the page. A persona that lets the product off the hook produces green
|
|
27
|
+
* runs that prove nothing.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_USER_MODEL = 'sonnet';
|
|
30
|
+
/** What `nextUserMessage` will actually run on, given an environment. */
|
|
31
|
+
export function resolveUserModel(env = process.env) {
|
|
32
|
+
return env.SIM_USER_MODEL || DEFAULT_USER_MODEL;
|
|
33
|
+
}
|
|
14
34
|
let neutralCwd;
|
|
15
|
-
// The persona
|
|
16
|
-
// look → act → look → reply, with room to spare.
|
|
17
|
-
//
|
|
18
|
-
// from
|
|
19
|
-
|
|
35
|
+
// The persona looks and acts before replying, so one turn is not enough:
|
|
36
|
+
// look → act → look → reply, with room to spare.
|
|
37
|
+
//
|
|
38
|
+
// Tuned from real runs, three times, and the number follows the shape of the
|
|
39
|
+
// work rather than a guess. It is the budget for ONE message: how many tool
|
|
40
|
+
// calls the person may make before answering.
|
|
41
|
+
//
|
|
42
|
+
// 6 was the cost of look/click/look/click/look — the most ordinary thing a
|
|
43
|
+
// person does on a multi-step page — leaving nothing for the reply.
|
|
44
|
+
//
|
|
45
|
+
// 12 was exactly the length of a guided workflow. In a run of the dataset
|
|
46
|
+
// creation case the assistant handed over the whole procedure in one message
|
|
47
|
+
// and the person executed it in one turn: click Create, choose the type, skip
|
|
48
|
+
// the init step, type a title, tick an option, continue — with a look between
|
|
49
|
+
// each, twelve calls of purposeful work and nothing wasted. The reply then had
|
|
50
|
+
// no budget left and the run was discarded.
|
|
51
|
+
//
|
|
52
|
+
// So a guided scenario costs roughly (steps × 2) + 1, and the assistant decides
|
|
53
|
+
// how many steps it hands over at once. 25 covers a full wizard driven in a
|
|
54
|
+
// single message, with the verification looks and the reply, and still stops a
|
|
55
|
+
// genuinely lost persona long before it could wander for minutes.
|
|
56
|
+
export const PERSONA_MAX_TURNS = 25;
|
|
20
57
|
export const PERCEPTION_INSTRUCTIONS = `You can look at the screen yourself with the look tool, and you can click and type
|
|
21
58
|
on the page. Before you say anything about what is or is not on the screen, look.
|
|
22
59
|
Never claim you cannot see something you have not looked for.`;
|
|
@@ -30,8 +67,15 @@ reply with your message; the runner types and sends it for you.`;
|
|
|
30
67
|
export function isDone(message) {
|
|
31
68
|
if (!message)
|
|
32
69
|
return false;
|
|
33
|
-
//
|
|
34
|
-
|
|
70
|
+
// The LAST line, not the whole message. The persona is asked for DONE and
|
|
71
|
+
// nothing else, and a capable one still signs off first ("That matches what
|
|
72
|
+
// I'm seeing — good.\n\nDONE"). Strict equality on the whole message missed
|
|
73
|
+
// that, so the runner sent the sign-off to the assistant and paid a full model
|
|
74
|
+
// request for a pleasantry nobody reads — once per case, every suite. It still
|
|
75
|
+
// has to be a line of its own, or "let me know when it is DONE" would end the
|
|
76
|
+
// run on the person's own words.
|
|
77
|
+
const lines = message.trim().split('\n').map(l => l.trim()).filter(Boolean);
|
|
78
|
+
let normalized = lines[lines.length - 1] ?? '';
|
|
35
79
|
// Strip surrounding quotes or backticks
|
|
36
80
|
if ((normalized.startsWith('"') && normalized.endsWith('"')) ||
|
|
37
81
|
(normalized.startsWith("'") && normalized.endsWith("'")) ||
|
|
@@ -107,7 +151,7 @@ export async function nextUserMessage(c, conversation, turnsLeft, opts) {
|
|
|
107
151
|
prompt: personaPrompt(conversation, turnsLeft),
|
|
108
152
|
options: {
|
|
109
153
|
...isolationOptions(neutralCwd),
|
|
110
|
-
model:
|
|
154
|
+
model: resolveUserModel(),
|
|
111
155
|
systemPrompt: personaSystemPrompt(c, !!opts?.perception, !!opts?.perception?.offLimits.length),
|
|
112
156
|
// Unconditional: a caller with no perception registers no mcpServers, so
|
|
113
157
|
// the persona has no tool to call and the loop still ends after the one
|
|
@@ -129,11 +173,18 @@ export async function nextUserMessage(c, conversation, turnsLeft, opts) {
|
|
|
129
173
|
: {})
|
|
130
174
|
}
|
|
131
175
|
})) {
|
|
176
|
+
// The LAST assistant message that carried text, not every one of them. With
|
|
177
|
+
// perception wired in the SDK emits an assistant message per reasoning step
|
|
178
|
+
// between tool calls, and appending them all sent the persona's inner
|
|
179
|
+
// monologue to the assistant as the person's own words — in one recorded run
|
|
180
|
+
// naming the tool the assistant should call, in another welding DONE onto the
|
|
181
|
+
// end of a sentence so isDone() missed it and the run ran on for an extra turn.
|
|
132
182
|
if (msg.type === 'assistant') {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
183
|
+
const said = (msg.message?.content ?? [])
|
|
184
|
+
.filter((block) => block.type === 'text' && block.text)
|
|
185
|
+
.map((block) => block.text);
|
|
186
|
+
if (said.length)
|
|
187
|
+
text = said.join('');
|
|
137
188
|
}
|
|
138
189
|
}
|
|
139
190
|
return text.trim();
|
package/report.js
CHANGED
|
@@ -4,6 +4,32 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
/**
|
|
8
|
+
* The counted facts, printed as facts. No threshold turns any of these into a
|
|
9
|
+
* failure: what they cost is a judgement, and the judge makes it with the
|
|
10
|
+
* transcript in hand.
|
|
11
|
+
*/
|
|
12
|
+
function metricsLine(run) {
|
|
13
|
+
const m = run?.metrics;
|
|
14
|
+
if (!m)
|
|
15
|
+
return '';
|
|
16
|
+
const parts = [
|
|
17
|
+
run?.toolsModel ? `[${run.assistantModel} / tools ${run.toolsModel}]` : '',
|
|
18
|
+
`${m.modelRequests} model requests for ${m.userMessages} user messages`,
|
|
19
|
+
m.requestsPerUserMessage === null ? '' : `(${m.requestsPerUserMessage}/message)`,
|
|
20
|
+
m.requestsByModel
|
|
21
|
+
? `(${Object.entries(m.requestsByModel).map(([role, n]) => `${role} ${n}`).join(', ')})`
|
|
22
|
+
: (m.nonLeadRequests ? `· ${m.nonLeadRequests} non-lead` : ''),
|
|
23
|
+
m.largestNonLeadPromptChars
|
|
24
|
+
? `· largest ${m.largestNonLeadPromptModel ?? 'non-lead'} prompt ${m.largestNonLeadPromptChars} chars`
|
|
25
|
+
: '',
|
|
26
|
+
`· ${m.textlessAssistantBubbles}/${m.assistantBubbles} bubbles tool-chips only`,
|
|
27
|
+
m.avgVisibleReplyChars === null ? '' : `· avg reply ${m.avgVisibleReplyChars} chars`,
|
|
28
|
+
m.duplicateToolCalls ? `· ${m.duplicateToolCalls} repeated tool calls` : '',
|
|
29
|
+
m.hostBlockChars === null ? '' : `· ${m.hostBlockChars} chars of host blocks`
|
|
30
|
+
];
|
|
31
|
+
return parts.filter(Boolean).join(' ');
|
|
32
|
+
}
|
|
7
33
|
// The verdict is written by a model and is untrusted by construction — a
|
|
8
34
|
// well-known slip is a stringified boolean ("false" instead of false), which
|
|
9
35
|
// would otherwise pass truthiness checks and silently report success.
|
|
@@ -56,12 +82,21 @@ export function reportCases(cases, evidenceDir) {
|
|
|
56
82
|
for (const row of rows)
|
|
57
83
|
console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' '));
|
|
58
84
|
for (const c of cases) {
|
|
85
|
+
const run = read(`sim-${c.name}.run.json`);
|
|
59
86
|
const verdict = read(`sim-${c.name}.verdict.json`);
|
|
60
|
-
|
|
87
|
+
const line = metricsLine(run);
|
|
88
|
+
const hasDetail = isValidVerdict(verdict, c.name) && (verdict.frictions.length > 0 || (verdict.findings?.length ?? 0) > 0);
|
|
89
|
+
if (!line && !hasDetail)
|
|
90
|
+
continue;
|
|
91
|
+
console.log(`\n${c.name}${isValidVerdict(verdict, c.name) ? `: ${verdict.summary}` : ''}`);
|
|
92
|
+
if (line)
|
|
93
|
+
console.log(` ${line}`);
|
|
94
|
+
if (!isValidVerdict(verdict, c.name))
|
|
61
95
|
continue;
|
|
62
|
-
console.log(`\n${c.name}: ${verdict.summary}`);
|
|
63
96
|
for (const f of verdict.frictions)
|
|
64
97
|
console.log(` - turn ${f.turn}: ${f.what} → ${f.effect}`);
|
|
98
|
+
for (const f of verdict.findings ?? [])
|
|
99
|
+
console.log(` · [${f.severity}] ${f.area}: ${f.what} (${f.evidence})`);
|
|
65
100
|
}
|
|
66
101
|
console.log(failures === 0 ? '\nall cases satisfied' : `\n${failures} case(s) need attention`);
|
|
67
102
|
return failures;
|
|
@@ -45,9 +45,54 @@ Ask the user to start anything that is down. Never start or stop dev processes y
|
|
|
45
45
|
SIM_CASES=air-quality npm run simulate # one case
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
Models are pinned by `SIM_ASSISTANT_MODEL` (default `sonnet`)
|
|
49
|
-
`
|
|
50
|
-
|
|
48
|
+
Models are pinned by `SIM_ASSISTANT_MODEL` (default `sonnet`),
|
|
49
|
+
`SIM_TOOLS_MODEL` (default `haiku`, for sub-agents, compaction and the
|
|
50
|
+
moderation guard) and `SIM_USER_MODEL` (default `sonnet`), and recorded per
|
|
51
|
+
run, so verdicts from different tiers are never compared silently.
|
|
52
|
+
|
|
53
|
+
The persona is on `sonnet` deliberately. On `haiku` it stopped enforcing its
|
|
54
|
+
own goal: a case whose goal said the answer had to be shown on screen was
|
|
55
|
+
ended with a chat-only reply and marked done, and another persona asserted it
|
|
56
|
+
could see nothing but the chat while its own `look` had just returned the
|
|
57
|
+
page. A persona that lets the product off the hook produces green runs that
|
|
58
|
+
prove nothing. It is the most expensive knob here, so lower it deliberately,
|
|
59
|
+
not by default.
|
|
60
|
+
|
|
61
|
+
**Confirm it started.** A suite takes minutes, so you will want to background
|
|
62
|
+
it — and a run that never launched looks exactly like a run still going. Check
|
|
63
|
+
the runner's own log first, and the sidecar as the stronger signal:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
tail -5 sim.log # state-setup passing means it is going
|
|
67
|
+
ls simulations/tmp/*.run.json # a file here means a case really started
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The sidecar is written before each case's body, but AFTER the login fixture,
|
|
71
|
+
which can take more than a minute on a cold page — so an empty
|
|
72
|
+
`simulations/tmp/` on its own is not proof of a stall. A log with no progress
|
|
73
|
+
at all is. Check the directory you are looking in, too: a backgrounded
|
|
74
|
+
`cd X && … &` runs the `cd` in a subshell, so a later `ls` reads whatever
|
|
75
|
+
directory you started from. One session read another repo's stale evidence
|
|
76
|
+
that way and called a dead run healthy.
|
|
77
|
+
|
|
78
|
+
**Wait on the process id, never on a text pattern.** `pgrep -f` matches full
|
|
79
|
+
command lines, including the command line of the waiter you are writing — so
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
until ! pgrep -f "playwright.sim.config"; do sleep 15; done # WRONG
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
matches itself and waits forever, silently, producing nothing. One session lost
|
|
86
|
+
an hour and three quarters to exactly this, and then repeated it while trying to
|
|
87
|
+
fix it. Capture the pid and watch that instead:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
nohup npm run simulate > sim.log 2>&1 &
|
|
91
|
+
SIMPID=$!
|
|
92
|
+
while kill -0 "$SIMPID" 2>/dev/null; do sleep 20; done
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
For the same reason, never `pkill -f` a pattern taken from your own script.
|
|
51
96
|
|
|
52
97
|
4. **Ignore the runner's own account of how it went.** The transcript at
|
|
53
98
|
`simulations/tmp/sim-<case>.json` is the evidence. A Playwright `passed` line
|
|
@@ -61,8 +106,16 @@ Ask the user to start anything that is down. Never start or stop dev processes y
|
|
|
61
106
|
|
|
62
107
|
- the case name and its goal
|
|
63
108
|
- the transcript path, `simulations/tmp/sim-<case>.json`
|
|
109
|
+
- the sidecar path, `simulations/tmp/sim-<case>.run.json`
|
|
64
110
|
- ask for the JSON verdict its own definition specifies
|
|
65
111
|
|
|
112
|
+
**Dispatch them all the same way.** What to look at, what matters, what you
|
|
113
|
+
suspect is wrong this time — none of that goes in the dispatch. The judge's
|
|
114
|
+
own definition sets its mandate, and it reads `docs/architecture/` itself. A
|
|
115
|
+
briefing you write per run steers the verdict toward what you already
|
|
116
|
+
believed and makes two runs' verdicts incomparable. If a judge is looking in
|
|
117
|
+
the wrong place, fix its definition, not one dispatch.
|
|
118
|
+
|
|
66
119
|
6. **Write each verdict** to `simulations/tmp/sim-<case>.verdict.json` as raw JSON.
|
|
67
120
|
Strip any code fence the judge added. A malformed verdict reports as
|
|
68
121
|
`not judged`, which is deliberate — check the file rather than being surprised.
|
|
@@ -73,14 +126,23 @@ Ask the user to start anything that is down. Never start or stop dev processes y
|
|
|
73
126
|
npm run simulate:report
|
|
74
127
|
```
|
|
75
128
|
|
|
76
|
-
Relay the summary
|
|
77
|
-
unsatisfactory, invalid, not judged, or never ran.
|
|
129
|
+
Relay the summary, the friction list and the findings. Exit code is non-zero
|
|
130
|
+
if any case was unsatisfactory, invalid, not judged, or never ran.
|
|
131
|
+
|
|
132
|
+
8. **Root-cause what it found.** The judge reads a record, not the source: it
|
|
133
|
+
says a tool result misled the assistant, not which line built that result.
|
|
134
|
+
That half is yours, and it is where a run becomes a change. For each friction
|
|
135
|
+
point and each finding worth acting on, go into the code, find what produced
|
|
136
|
+
it, and say so — a defect you confirmed, a design decision the judge could
|
|
137
|
+
not see, or a harness artefact. Report that, not the verdict verbatim.
|
|
78
138
|
|
|
79
139
|
## Reading the result
|
|
80
140
|
|
|
81
|
-
The friction list is the
|
|
82
|
-
|
|
83
|
-
|
|
141
|
+
The friction list is the person's side, and it is what "unsatisfactory" is
|
|
142
|
+
actually about: which reply or tool result misled them and what they did next.
|
|
143
|
+
The findings are everything else the run exposed — cost, what the conversation
|
|
144
|
+
was like to read, tools, the product, the harness. Both are claims about a
|
|
145
|
+
record; both need confirming against the code before anyone acts on them.
|
|
84
146
|
|
|
85
147
|
Rate limits are the practical ceiling: three Claude roles per case on one
|
|
86
148
|
subscription. A run cut short by a rate limit is an **invalid run**, not a product
|
|
@@ -4,46 +4,82 @@ description: Judge one scenario simulation transcript and return a JSON verdict.
|
|
|
4
4
|
tools: Read
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
You are judging
|
|
8
|
-
|
|
7
|
+
You are judging one simulated conversation between a person and a chat
|
|
8
|
+
assistant, from the record the run left behind.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
the
|
|
10
|
+
Two questions, in this order. **Did the person get what they came for** — that
|
|
11
|
+
is the one that decides `satisfied`, and nothing else does. Then: **what was
|
|
12
|
+
wrong with how it went**, anywhere in what the record shows you. The second
|
|
13
|
+
question is open. Cost and efficiency, the readability of what the person saw,
|
|
14
|
+
tool descriptions and tool results, the product's own behaviour, the chat
|
|
15
|
+
harness itself — all of it is in scope, and so is anything you notice that this
|
|
16
|
+
list does not name. You are not filling in a form; you are the one reader who
|
|
17
|
+
saw the whole run.
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- `
|
|
19
|
-
`
|
|
20
|
-
|
|
19
|
+
## What you are given
|
|
20
|
+
|
|
21
|
+
A case name, the person's goal, and paths to the evidence. Read the files with
|
|
22
|
+
`Read`; do not ask for anything to be pasted.
|
|
23
|
+
|
|
24
|
+
- `simulations/tmp/sim-<case>.json` — the transcript:
|
|
25
|
+
- `conversation` — what the person and the assistant said, as rendered on screen
|
|
26
|
+
- `gateway` — every request the page made, carrying the tools it offered, the
|
|
27
|
+
tool calls the assistant actually made, and in `toolResults` what each of
|
|
28
|
+
those calls answered. Use `toolResults` to check an assistant's claim against
|
|
29
|
+
what the tool really returned; large results are truncated, and both lists are
|
|
30
|
+
cumulative.
|
|
31
|
+
- `consoleErrors` — browser errors during the run
|
|
32
|
+
- `observations` — what the person actually looked at and did, per turn:
|
|
33
|
+
`{ turn, tool, args, result }`. `look` returns the accessibility outline of
|
|
34
|
+
the screen at that moment.
|
|
35
|
+
- `simulations/tmp/sim-<case>.run.json` — the sidecar: which models ran, how long
|
|
36
|
+
it took, and `metrics`, a few counts derived from the transcript. Read them as
|
|
37
|
+
facts, not as a score: nothing in `metrics` is a pass mark, and a number only
|
|
38
|
+
means something once you have seen in the transcript what produced it.
|
|
39
|
+
|
|
40
|
+
**Read the architecture documentation before judging.** `docs/architecture/` in
|
|
41
|
+
this repository describes what the assistant is supposed to be, what the tools
|
|
42
|
+
promise, and what the chat harness does on its own. Start with the document
|
|
43
|
+
covering the agent or chat integration, then read whatever looks relevant to
|
|
44
|
+
what you saw. Without it you will report intended design as a defect and miss
|
|
45
|
+
the places the implementation diverges from what was written down.
|
|
46
|
+
|
|
47
|
+
## How to read the record, and where it misleads
|
|
48
|
+
|
|
49
|
+
`gateway` records what the browser SENT, and each request resends the whole
|
|
50
|
+
conversation so far — so the assistant's FINAL reply never appears there,
|
|
51
|
+
because no later request carries it. Read the last assistant turn from
|
|
52
|
+
`conversation`. Never conclude "the assistant never answered" from `gateway`.
|
|
53
|
+
|
|
54
|
+
`gateway[].toolCalls` is CUMULATIVE: exchange N holds every tool call from
|
|
55
|
+
1..N. That is a history, not repetition. `metrics.duplicateToolCalls` already
|
|
56
|
+
accounts for this; if you count repetition yourself, compare across exchanges
|
|
57
|
+
first.
|
|
58
|
+
|
|
59
|
+
Not every request is the assistant answering the person. Compaction, moderation
|
|
60
|
+
and sub-agents each run on their own model role, with their own short history
|
|
61
|
+
interleaved among the lead's. `metrics.requestsByModel` says which role served
|
|
62
|
+
how many — read it before attributing spend, and never call a request a
|
|
63
|
+
sub-agent dispatch unless a `subagent_*` call appears in `toolCalls`. A judge
|
|
64
|
+
once reported three `summarizer` compaction calls as sub-agent work in a run
|
|
65
|
+
that made no sub-agent call at all.
|
|
21
66
|
|
|
22
67
|
A claim about what is on screen must be supported by a preceding `look` in
|
|
23
|
-
`observations`. A persona asserting a visual fact it never observed is a
|
|
24
|
-
fault, not product friction — say so
|
|
25
|
-
friction
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
`
|
|
29
|
-
the whole conversation so far — so the assistant's FINAL reply of a conversation
|
|
30
|
-
never appears there, because no later request resends it. Read the final
|
|
31
|
-
assistant turn from `conversation`, not `gateway`, and never conclude "the
|
|
32
|
-
assistant never answered" from its absence in `gateway`.
|
|
33
|
-
|
|
34
|
-
`gateway[].toolCalls` is CUMULATIVE: exchange N contains every tool call from
|
|
35
|
-
turns 1..N, not just that turn's. Do not treat this as the same tool call being
|
|
36
|
-
repeated — compare call counts across exchanges before filing repetition as a
|
|
37
|
-
friction point, or you will report calls that never actually recurred.
|
|
68
|
+
`observations`. A persona asserting a visual fact it never observed is a
|
|
69
|
+
HARNESS fault, not product friction — say so in `notes` and do not count it as
|
|
70
|
+
friction. This has happened: a run had the person insist a panel was closed
|
|
71
|
+
having never looked, and the judge reported it as a product failure.
|
|
72
|
+
|
|
73
|
+
## `satisfied` and `frictions` — the person's side
|
|
38
74
|
|
|
39
75
|
Judge the run against the goal, not against your idea of a good answer. The
|
|
40
|
-
person is not a tester: if they had to ask three times, that is
|
|
41
|
-
|
|
76
|
+
person is not a tester: if they had to ask three times, that is friction even
|
|
77
|
+
when the final answer was right.
|
|
78
|
+
|
|
79
|
+
`satisfied` is true only if the goal was actually met and visibly so.
|
|
42
80
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
That is what turns a run into a concrete change to a prompt or a tool
|
|
46
|
-
description. Look especially for:
|
|
81
|
+
A friction point names the reply or tool result that misled the person and what
|
|
82
|
+
they did next. Look especially for:
|
|
47
83
|
- the assistant claiming it did something the `gateway` record shows it never did
|
|
48
84
|
- a tool offered but never used when it was obviously needed, or called with
|
|
49
85
|
arguments that misread the person's words
|
|
@@ -51,6 +87,56 @@ description. Look especially for:
|
|
|
51
87
|
- the person having to supply information the assistant could have looked up
|
|
52
88
|
- an answer that is correct but never shown where the person asked for it
|
|
53
89
|
|
|
90
|
+
A friction's `turn` is the 1-based index of the USER turn it occurred on — the
|
|
91
|
+
Nth message the person sent, counting only their messages in `conversation`.
|
|
92
|
+
A friction caused by the reply to the person's 3rd message is `"turn": 3`.
|
|
93
|
+
Count this way so two judges reading the same transcript agree.
|
|
94
|
+
|
|
95
|
+
An empty `frictions` array is a real answer when a run went cleanly.
|
|
96
|
+
|
|
97
|
+
## `findings` — everything else the run exposed
|
|
98
|
+
|
|
99
|
+
Separate from friction, because these are for whoever maintains the system
|
|
100
|
+
rather than about what the person lived through. Anything the record supports:
|
|
101
|
+
|
|
102
|
+
- **cost** — how much work the run took for what it delivered. `metrics` gives
|
|
103
|
+
you requests per user message, the split by model role, the largest prompt
|
|
104
|
+
handed to a role other than the lead's, and how many characters the
|
|
105
|
+
application injected into the conversation. A summarizer legitimately carries
|
|
106
|
+
the whole conversation, so read which role took a big prompt before calling
|
|
107
|
+
it waste. Then go look: which turns spent the requests,
|
|
108
|
+
and was the spending doing anything?
|
|
109
|
+
- **conversation** — what the person had to read. Length, repetition, hedging,
|
|
110
|
+
restating what is already on their screen, silence where a word was owed.
|
|
111
|
+
`metrics.textlessAssistantBubbles` counts bubbles with no prose in them. These
|
|
112
|
+
are almost always tool-call bubbles, and the page DOES render a chip naming the
|
|
113
|
+
tool — so the count is not evidence of silence, and the `observations` outlines
|
|
114
|
+
will show you the chips. What it tells you is how much of the run the person
|
|
115
|
+
watched as tool names rather than sentences.
|
|
116
|
+
- **tools** — a description that invites the wrong call, a result the model
|
|
117
|
+
visibly misread, a required argument the model had no way to know, an error
|
|
118
|
+
the assistant swallowed.
|
|
119
|
+
- **product** — what the application did, or failed to do, behind the
|
|
120
|
+
conversation. `consoleErrors` and `observations` are the evidence.
|
|
121
|
+
- **harness** — the chat itself: turn handling, streaming, the simulated
|
|
122
|
+
person's own tooling, anything in how the run was conducted rather than in
|
|
123
|
+
what was said.
|
|
124
|
+
|
|
125
|
+
Severity is your call: `high` for something that cost the person the goal or
|
|
126
|
+
would in a nearby run, `medium` for real waste or real confusion, `low` for
|
|
127
|
+
things worth knowing. Evidence is a pointer a maintainer can check — a turn
|
|
128
|
+
number, a tool name, a count, a quoted line.
|
|
129
|
+
|
|
130
|
+
**Say what is wrong, not why the code does it.** You have the record, not the
|
|
131
|
+
source. Naming a file or a function you did not read is a guess that sends
|
|
132
|
+
someone down the wrong path; the agent that dispatched you has the codebase and
|
|
133
|
+
does the root-causing. Describe the defect precisely enough for them to find it.
|
|
134
|
+
|
|
135
|
+
Report nothing you cannot point at in the record. A short `findings` list from a
|
|
136
|
+
clean run is worth more than a padded one.
|
|
137
|
+
|
|
138
|
+
## Output
|
|
139
|
+
|
|
54
140
|
Return ONLY raw JSON, no code fence, in exactly this shape:
|
|
55
141
|
|
|
56
142
|
{
|
|
@@ -60,15 +146,11 @@ Return ONLY raw JSON, no code fence, in exactly this shape:
|
|
|
60
146
|
"frictions": [
|
|
61
147
|
{ "turn": <number>, "what": "<what the assistant or a tool did>", "effect": "<what the person concluded or had to do>" }
|
|
62
148
|
],
|
|
63
|
-
"
|
|
149
|
+
"findings": [
|
|
150
|
+
{ "area": "cost" | "conversation" | "tools" | "product" | "harness" | "<your own>", "severity": "high" | "medium" | "low", "what": "<the defect>", "evidence": "<where in the record>" }
|
|
151
|
+
],
|
|
152
|
+
"notes": "<the overall opinion a maintainer should hear, or empty>"
|
|
64
153
|
}
|
|
65
154
|
|
|
66
|
-
`
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
A friction's `turn` is the 1-based index of the USER turn it occurred on — the
|
|
70
|
-
Nth message the person sent, counting only the person's messages in
|
|
71
|
-
`conversation` and not the assistant's. So a friction caused by the
|
|
72
|
-
assistant's reply to the person's 3rd message is still `"turn": 3`. Count
|
|
73
|
-
consistently this way so that two judges reading the same transcript would
|
|
74
|
-
agree on the number.
|
|
155
|
+
`notes` is where the judgement that fits no list goes: how the run read, what
|
|
156
|
+
you would change first, what you are unsure about.
|
package/transcript.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { computeMetrics } from "./metrics.js";
|
|
11
12
|
/**
|
|
12
13
|
* Default evidence location, resolved against the host repo's cwd. Kept as the
|
|
13
14
|
* default rather than baked in: `reportCases(cases, evidenceDir)` already lets
|
|
@@ -17,5 +18,9 @@ export const evidenceDir = path.join(process.cwd(), 'simulations', 'tmp');
|
|
|
17
18
|
export function writeEvidence(name, transcript, sidecar, dir = evidenceDir) {
|
|
18
19
|
fs.mkdirSync(dir, { recursive: true });
|
|
19
20
|
fs.writeFileSync(path.join(dir, `sim-${name}.json`), JSON.stringify(transcript, null, 2));
|
|
20
|
-
|
|
21
|
+
// Derived here rather than asked of the host: every host would compute the
|
|
22
|
+
// same thing from the same transcript, and two of these counts are easy to
|
|
23
|
+
// get wrong (see metrics.ts).
|
|
24
|
+
const withMetrics = { ...sidecar, metrics: computeMetrics(transcript) };
|
|
25
|
+
fs.writeFileSync(path.join(dir, `sim-${name}.run.json`), JSON.stringify(withMetrics, null, 2));
|
|
21
26
|
}
|
package/types.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { GatewayExchange } from './gateway-capture.ts';
|
|
5
5
|
import type { Observation } from './page-perception.ts';
|
|
6
|
+
import type { RunMetrics } from './metrics.ts';
|
|
6
7
|
export type SimulationCase = {
|
|
7
8
|
/** Evidence files are named after this; keep it filesystem-safe. */
|
|
8
9
|
name: string;
|
|
@@ -32,8 +33,14 @@ export type RunSidecar = {
|
|
|
32
33
|
valid: boolean;
|
|
33
34
|
error?: string;
|
|
34
35
|
assistantModel: string;
|
|
36
|
+
/** The tier the background roles ran on (sub-agents, compaction, moderation),
|
|
37
|
+
* when the host pins it separately from the assistant's. */
|
|
38
|
+
toolsModel?: string;
|
|
35
39
|
userModel: string;
|
|
36
40
|
turns: number;
|
|
37
41
|
durationMs: number;
|
|
38
42
|
finishedAt: string;
|
|
43
|
+
/** Derived from the transcript by `writeEvidence`, so a host gets them without
|
|
44
|
+
* asking. Evidence for the judge, never a score. */
|
|
45
|
+
metrics?: RunMetrics;
|
|
39
46
|
};
|