@data-fair/lib-agents-sim 0.3.0 → 0.4.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 +89 -10
- package/bin/init.js +2 -2
- package/bridge/index.js +0 -0
- package/chat-driver.d.ts +2 -0
- package/chat-driver.js +39 -6
- package/index.d.ts +4 -2
- package/index.js +3 -2
- package/package.json +1 -1
- package/page-perception.d.ts +37 -0
- package/page-perception.js +163 -0
- package/persona.d.ts +15 -2
- package/persona.js +79 -18
- package/templates/{simulate-skill.md → agents-sim-skill.md} +7 -3
- package/templates/simulation-judge.md +10 -1
- package/types.d.ts +2 -0
package/README.md
CHANGED
|
@@ -51,8 +51,8 @@ Agent SDK may hoist zod 4 and break `ai`'s type inference. Add
|
|
|
51
51
|
npx df-agents-sim-init [--force]
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
Copies the `/
|
|
55
|
-
into your repo's `.claude/skills/
|
|
54
|
+
Copies the `/agents-sim` skill and the `simulation-judge` sub-agent definition
|
|
55
|
+
into your repo's `.claude/skills/agents-sim/SKILL.md` and
|
|
56
56
|
`.claude/agents/simulation-judge.md`. These cannot be loaded from
|
|
57
57
|
`node_modules` — Claude Code reads them from the repository — so they are
|
|
58
58
|
copied, not referenced, and **can drift** from the version in this package.
|
|
@@ -73,11 +73,18 @@ session's locale. Pass the locale your application runs in — `createChatDriver
|
|
|
73
73
|
{ locale: 'fr' })` — or the run dies as a 15-minute "element not found" with
|
|
74
74
|
nothing pointing at the cause. The default is `'en'`.
|
|
75
75
|
|
|
76
|
+
**Send recovery.** `sendMessage` bounds its fill/click at `SEND_TIMEOUT_MS`
|
|
77
|
+
(15s) rather than waiting indefinitely. If the first attempt fails — typically
|
|
78
|
+
an overlay left open over the composer — it presses Escape (the ordinary way a
|
|
79
|
+
person dismisses something in their way) and retries once before throwing. It
|
|
80
|
+
never passes `{ force: true }`: punching through an overlay a real user could
|
|
81
|
+
not reach would report a success a person could never have had.
|
|
82
|
+
|
|
76
83
|
```ts
|
|
77
84
|
import { test } from '@playwright/test'
|
|
78
85
|
import {
|
|
79
|
-
createChatDriver, captureGateway, nextUserMessage, isDone,
|
|
80
|
-
writeEvidence, selectCases, type Transcript, type SimulationCase
|
|
86
|
+
createChatDriver, chatDriverStrings, captureGateway, nextUserMessage, isDone,
|
|
87
|
+
writeEvidence, selectCases, createPagePerception, type Transcript, type SimulationCase
|
|
81
88
|
} from '@data-fair/lib-agents-sim'
|
|
82
89
|
|
|
83
90
|
const cases: SimulationCase[] = [
|
|
@@ -89,13 +96,25 @@ for (const simCase of selectCases(cases, [])) {
|
|
|
89
96
|
const gateway = captureGateway(page)
|
|
90
97
|
await page.goto(simCase.route)
|
|
91
98
|
|
|
92
|
-
const
|
|
99
|
+
const locale = 'en' as const
|
|
100
|
+
const strings = chatDriverStrings(locale)
|
|
101
|
+
const chat = createChatDriver(page.frameLocator('iframe'), { locale })
|
|
93
102
|
const conversation: Array<{ role: string, text: string }> = []
|
|
103
|
+
// Lets the persona look at, click and type into the real page instead of
|
|
104
|
+
// guessing at what is on screen — see "Give the persona eyes" below.
|
|
105
|
+
// offLimits is not optional in practice: without it the persona can (and
|
|
106
|
+
// will) click into the composer and press Send itself, double-sending its
|
|
107
|
+
// message on top of the runner's own send below.
|
|
108
|
+
const perception = createPagePerception(
|
|
109
|
+
[{ label: 'page', root: page }],
|
|
110
|
+
{ offLimits: [strings.input, strings.send, strings.stop, strings.reset] }
|
|
111
|
+
)
|
|
94
112
|
let error: string | undefined
|
|
95
113
|
|
|
96
114
|
try {
|
|
97
115
|
for (let i = 0; i < simCase.maxTurns; i++) {
|
|
98
|
-
|
|
116
|
+
perception.setTurn(i + 1)
|
|
117
|
+
const message = await nextUserMessage(simCase, conversation, simCase.maxTurns - i, { perception })
|
|
99
118
|
if (isDone(message)) break
|
|
100
119
|
await chat.sendMessage(message)
|
|
101
120
|
await chat.waitForTurn()
|
|
@@ -110,7 +129,7 @@ for (const simCase of selectCases(cases, [])) {
|
|
|
110
129
|
error = err instanceof Error ? err.message : String(err)
|
|
111
130
|
}
|
|
112
131
|
|
|
113
|
-
const transcript: Transcript = { case: simCase.name, goal: simCase.goal, persona: simCase.persona, route: simCase.route, conversation, gateway, consoleErrors: [] }
|
|
132
|
+
const transcript: Transcript = { case: simCase.name, goal: simCase.goal, persona: simCase.persona, route: simCase.route, conversation, gateway, consoleErrors: [], observations: perception.observations }
|
|
114
133
|
// `valid` is derived, never hardcoded: the sidecar exists to tell a run that
|
|
115
134
|
// really happened apart from one that fell over, so that `reportCases` says
|
|
116
135
|
// "invalid (…)" instead of re-reporting the previous run's verdict.
|
|
@@ -125,11 +144,71 @@ for (const simCase of selectCases(cases, [])) {
|
|
|
125
144
|
```
|
|
126
145
|
|
|
127
146
|
Then judge each written transcript with the `simulation-judge` sub-agent (via
|
|
128
|
-
the copied `/
|
|
147
|
+
the copied `/agents-sim` skill), and turn the evidence directory into a pass/fail
|
|
129
148
|
summary with `reportCases(cases, evidenceDir)` — the host repo's own report
|
|
130
149
|
script decides where cases live and what to do with the failure count it
|
|
131
150
|
returns.
|
|
132
151
|
|
|
152
|
+
### Give the persona eyes
|
|
153
|
+
|
|
154
|
+
`createPagePerception(roots, opts?)` gives the simulated user a
|
|
155
|
+
`look`/`click`/`type` MCP tool set over the real Playwright page(s), so it can
|
|
156
|
+
check what is actually on screen instead of guessing. Pass one root per
|
|
157
|
+
visible surface — a chat embedded in an iframe has both the host page and the
|
|
158
|
+
frame:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const perception = createPagePerception(
|
|
162
|
+
[
|
|
163
|
+
{ label: 'page', root: page },
|
|
164
|
+
{ label: 'chat panel', root: page.frameLocator('iframe') }
|
|
165
|
+
],
|
|
166
|
+
{ offLimits: [strings.input, strings.send, strings.stop, strings.reset] }
|
|
167
|
+
)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Pass `offLimits`.** It is the second argument's only option, and it is not
|
|
171
|
+
optional in practice: it is the list of accessible names — typically the
|
|
172
|
+
composer's input, send, stop and reset controls, from `chatDriverStrings` — that
|
|
173
|
+
`click`/`type` refuse before even looking the element up. The persona's system
|
|
174
|
+
prompt is told *"the composer will refuse you"* only when this list is
|
|
175
|
+
non-empty (an empty or omitted `offLimits` refuses nothing, silently). Without
|
|
176
|
+
it, the persona can and will type its own message into the composer and press
|
|
177
|
+
Send itself — double-sending on top of the runner's own `chat.sendMessage`
|
|
178
|
+
below, and usually invalidating the run. A `reset` control is worth including
|
|
179
|
+
too: a mid-run click erases the transcript the run exists to produce.
|
|
180
|
+
|
|
181
|
+
Before each turn, tell it which turn is starting — this stamps every
|
|
182
|
+
observation the persona records during that turn — then pass it through
|
|
183
|
+
`nextUserMessage`'s options so the persona's query gets the tool set:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
perception.setTurn(i + 1)
|
|
187
|
+
const message = await nextUserMessage(simCase, conversation, simCase.maxTurns - i, { perception })
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Every `look`/`click`/`type` call is recorded into `perception.observations` as
|
|
191
|
+
`{ turn, tool, args, result }`; put that array into the transcript's
|
|
192
|
+
`observations` field so the judge can check a visual claim against what was
|
|
193
|
+
actually seen. **Without `perception`, the persona cannot see the page at
|
|
194
|
+
all** — do not write a case or a judge prompt that expects it to notice or
|
|
195
|
+
react to anything visual (a panel opening, a chart rendering, a result
|
|
196
|
+
appearing) unless perception is wired in.
|
|
197
|
+
|
|
198
|
+
`df-agents-sim-init` copies the `simulation-judge` definition into your repo,
|
|
199
|
+
and the copied version now includes the instruction to check visual claims
|
|
200
|
+
against `observations`. If you already ran `df-agents-sim-init` before this
|
|
201
|
+
was added, re-run `npx df-agents-sim-init --force` to pick it up — otherwise
|
|
202
|
+
your judge keeps trusting unverified visual claims.
|
|
203
|
+
|
|
204
|
+
**Breaking change in 0.3.0.** `Transcript.observations` is a required field,
|
|
205
|
+
not an optional one — deliberately: an optional field would let a host wire up
|
|
206
|
+
`perception` and forget to add `observations` to its transcript object, and
|
|
207
|
+
ship runs that look valid while the judge sees no evidence at all. Upgrading
|
|
208
|
+
from 0.2.0 means adding `observations: perception?.observations ?? []` (or
|
|
209
|
+
`[]` where perception is not used) to the transcript you build; a TypeScript
|
|
210
|
+
compile error will name the field for you.
|
|
211
|
+
|
|
133
212
|
### Where the evidence goes
|
|
134
213
|
|
|
135
214
|
`writeEvidence(name, transcript, sidecar, dir?)` writes `sim-<name>.json` (the
|
|
@@ -141,7 +220,7 @@ from the repository root. Pass `dir` explicitly to put evidence anywhere else,
|
|
|
141
220
|
and hand the same directory to `reportCases(cases, dir)` so the reader and the
|
|
142
221
|
writer agree.
|
|
143
222
|
|
|
144
|
-
## Scripts the copied `/
|
|
223
|
+
## Scripts the copied `/agents-sim` skill expects
|
|
145
224
|
|
|
146
225
|
`df-agents-sim-init` copies the skill **verbatim**, and the skill refers to npm
|
|
147
226
|
scripts by the names the origin repository uses. It cannot know yours, so define
|
|
@@ -166,5 +245,5 @@ these three in your `package.json` (adjust the paths to your layout):
|
|
|
166
245
|
on the failure count it returns.
|
|
167
246
|
|
|
168
247
|
If you prefer different names, edit the copied
|
|
169
|
-
`.claude/skills/
|
|
248
|
+
`.claude/skills/agents-sim/SKILL.md` to match — but remember that a later
|
|
170
249
|
`df-agents-sim-init --force` overwrites it.
|
package/bin/init.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Copies the judge definition and the /
|
|
3
|
+
* Copies the judge definition and the /agents-sim skill into the consuming repo's
|
|
4
4
|
* .claude/ directory. They cannot be loaded from node_modules — Claude Code reads
|
|
5
5
|
* them from the repository — so they are copied and can drift. The version is
|
|
6
6
|
* printed so drift is at least detectable.
|
|
@@ -14,7 +14,7 @@ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'u
|
|
|
14
14
|
const cwd = process.cwd();
|
|
15
15
|
const targets = [
|
|
16
16
|
{ from: 'simulation-judge.md', to: path.join(cwd, '.claude', 'agents', 'simulation-judge.md') },
|
|
17
|
-
{ from: '
|
|
17
|
+
{ from: 'agents-sim-skill.md', to: path.join(cwd, '.claude', 'skills', 'agents-sim', 'SKILL.md') }
|
|
18
18
|
];
|
|
19
19
|
for (const { from, to } of targets) {
|
|
20
20
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
package/bridge/index.js
CHANGED
|
File without changes
|
package/chat-driver.d.ts
CHANGED
|
@@ -20,8 +20,10 @@ export declare function chatDriverStrings(locale: ChatDriverLocale): {
|
|
|
20
20
|
input: string;
|
|
21
21
|
send: string;
|
|
22
22
|
stop: string;
|
|
23
|
+
reset: string;
|
|
23
24
|
};
|
|
24
25
|
export declare const TURN_TIMEOUT_MS: number;
|
|
26
|
+
export declare const SEND_TIMEOUT_MS = 15000;
|
|
25
27
|
export declare function createChatDriver(root: ChatRoot, opts?: {
|
|
26
28
|
locale?: ChatDriverLocale;
|
|
27
29
|
}): {
|
package/chat-driver.js
CHANGED
|
@@ -16,12 +16,16 @@
|
|
|
16
16
|
import { expect } from '@playwright/test';
|
|
17
17
|
/**
|
|
18
18
|
* The chat takes its locale from the session (`i18n_lang`), so a host
|
|
19
|
-
* application running in French renders a French composer.
|
|
20
|
-
*
|
|
19
|
+
* application running in French renders a French composer. `reset` is the
|
|
20
|
+
* header's "Reset conversation" icon button (AgentChatHeader.vue) — its
|
|
21
|
+
* accessible name comes from the same `t()`-into-`title` pattern as the
|
|
22
|
+
* composer's own strings, so it is sourced here rather than as a literal at
|
|
23
|
+
* the call site. These are the only locale-dependent selectors:
|
|
24
|
+
* `readConversation` matches on classes.
|
|
21
25
|
*/
|
|
22
26
|
const STRINGS = {
|
|
23
|
-
en: { input: 'Type your message...', send: 'Send', stop: 'Stop' },
|
|
24
|
-
fr: { input: 'Tapez votre message...', send: 'Envoyer', stop: 'Arrêter' }
|
|
27
|
+
en: { input: 'Type your message...', send: 'Send', stop: 'Stop', reset: 'Reset conversation' },
|
|
28
|
+
fr: { input: 'Tapez votre message...', send: 'Envoyer', stop: 'Arrêter', reset: 'Réinitialiser la conversation' }
|
|
25
29
|
};
|
|
26
30
|
export function chatDriverStrings(locale) {
|
|
27
31
|
const strings = STRINGS[locale];
|
|
@@ -35,12 +39,41 @@ export function chatDriverStrings(locale) {
|
|
|
35
39
|
// legitimate multi-step turn has no fixed ceiling on total time. This bounds the
|
|
36
40
|
// harness generously rather than recording a slow-but-working turn as a failure.
|
|
37
41
|
export const TURN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
42
|
+
// Bounds a wedged page — e.g. the persona's own click/look/type tools
|
|
43
|
+
// (page-perception.ts) left an overlay open over the composer — so a stuck
|
|
44
|
+
// send fails in seconds rather than retrying against a stable-but-unreachable
|
|
45
|
+
// element until the test runner's own timeout kills the whole case 15 minutes
|
|
46
|
+
// later with no diagnosis. Unrelated to TURN_TIMEOUT_MS, which bounds a
|
|
47
|
+
// legitimately long model turn once the message has actually been sent.
|
|
48
|
+
export const SEND_TIMEOUT_MS = 15000;
|
|
38
49
|
export function createChatDriver(root, opts = {}) {
|
|
39
50
|
const strings = chatDriverStrings(opts.locale ?? 'en');
|
|
40
51
|
return {
|
|
41
52
|
async sendMessage(text) {
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
const fillAndSend = async () => {
|
|
54
|
+
await root.getByPlaceholder(strings.input).fill(text, { timeout: SEND_TIMEOUT_MS });
|
|
55
|
+
await root.getByRole('button', { name: strings.send }).click({ timeout: SEND_TIMEOUT_MS });
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
await fillAndSend();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Not { force: true }: punching through an overlay a real user could
|
|
62
|
+
// not reach would make the harness report successes a person could
|
|
63
|
+
// never have had. Escape is the ordinary way a person dismisses a
|
|
64
|
+
// dialog that is in their way, so try that and retry once — on a
|
|
65
|
+
// locator, not `root.keyboard`, since root is a FrameLocator (no
|
|
66
|
+
// keyboard) when the chat is embedded.
|
|
67
|
+
await root.locator('body').press('Escape', { timeout: SEND_TIMEOUT_MS }).catch(() => { });
|
|
68
|
+
try {
|
|
69
|
+
await fillAndSend();
|
|
70
|
+
}
|
|
71
|
+
catch (secondErr) {
|
|
72
|
+
const detail = secondErr instanceof Error ? secondErr.message : String(secondErr);
|
|
73
|
+
throw new Error('could not operate the chat composer (fill/send) even after pressing Escape — ' +
|
|
74
|
+
`the page may have been left in a modal or scrolled state by the simulated user. Underlying error: ${detail}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
44
77
|
},
|
|
45
78
|
async waitForTurn(timeoutMs = TURN_TIMEOUT_MS) {
|
|
46
79
|
const stop = root.getByRole('button', { name: strings.stop });
|
package/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
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 } from './persona.ts';
|
|
4
|
+
export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS } from './persona.ts';
|
|
5
5
|
export { writeEvidence, evidenceDir } from './transcript.ts';
|
|
6
6
|
export { selectCases } from './cases.ts';
|
|
7
7
|
export { reportCases } from './report.ts';
|
|
8
|
-
export { createChatDriver, type ChatRoot, TURN_TIMEOUT_MS } from './chat-driver.ts';
|
|
8
|
+
export { createChatDriver, chatDriverStrings, type ChatRoot, type ChatDriverLocale, TURN_TIMEOUT_MS, SEND_TIMEOUT_MS } from './chat-driver.ts';
|
|
9
|
+
export { createPagePerception, truncate, SNAPSHOT_CAP, ACTION_TIMEOUT_MS, MCP_SERVER_NAME as PAGE_MCP_SERVER_NAME } from './page-perception.ts';
|
|
10
|
+
export type { PerceptionRoot, Observation, PagePerception } from './page-perception.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 } from "./persona.js";
|
|
3
|
+
export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS } from "./persona.js";
|
|
4
4
|
export { writeEvidence, evidenceDir } from "./transcript.js";
|
|
5
5
|
export { selectCases } from "./cases.js";
|
|
6
6
|
export { reportCases } from "./report.js";
|
|
7
|
-
export { createChatDriver, TURN_TIMEOUT_MS } from "./chat-driver.js";
|
|
7
|
+
export { createChatDriver, chatDriverStrings, TURN_TIMEOUT_MS, SEND_TIMEOUT_MS } from "./chat-driver.js";
|
|
8
|
+
export { createPagePerception, truncate, SNAPSHOT_CAP, ACTION_TIMEOUT_MS, MCP_SERVER_NAME as PAGE_MCP_SERVER_NAME } from "./page-perception.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@data-fair/lib-agents-sim",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ChatRoot } from './chat-driver.ts';
|
|
2
|
+
export declare const MCP_SERVER_NAME = "page";
|
|
3
|
+
export declare const SNAPSHOT_CAP = 4000;
|
|
4
|
+
export declare const ACTION_TIMEOUT_MS = 15000;
|
|
5
|
+
export type PerceptionRoot = {
|
|
6
|
+
label: string;
|
|
7
|
+
root: ChatRoot;
|
|
8
|
+
};
|
|
9
|
+
export type Observation = {
|
|
10
|
+
turn: number;
|
|
11
|
+
tool: string;
|
|
12
|
+
args: unknown;
|
|
13
|
+
result: string;
|
|
14
|
+
};
|
|
15
|
+
export type PagePerception = {
|
|
16
|
+
server: {
|
|
17
|
+
type: 'sdk';
|
|
18
|
+
name: string;
|
|
19
|
+
instance: unknown;
|
|
20
|
+
alwaysLoad: true;
|
|
21
|
+
};
|
|
22
|
+
observations: Observation[];
|
|
23
|
+
setTurn: (turn: number) => void;
|
|
24
|
+
toolNames: string[];
|
|
25
|
+
call: (tool: string, args: Record<string, unknown>) => Promise<string>;
|
|
26
|
+
/**
|
|
27
|
+
* The names passed as `opts.offLimits`, verbatim (empty when none were).
|
|
28
|
+
* `persona.ts` reads this to decide whether the composer-refusal sentence in
|
|
29
|
+
* its system prompt is a true statement — with no offLimits, nothing refuses
|
|
30
|
+
* anything, so the prompt must not claim otherwise.
|
|
31
|
+
*/
|
|
32
|
+
offLimits: string[];
|
|
33
|
+
};
|
|
34
|
+
export declare function truncate(text: string): string;
|
|
35
|
+
export declare function createPagePerception(roots: PerceptionRoot[], opts?: {
|
|
36
|
+
offLimits?: string[];
|
|
37
|
+
}): PagePerception;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the simulated user can perceive and do.
|
|
3
|
+
*
|
|
4
|
+
* Three tools, shaped like a person rather than like a test framework: look at
|
|
5
|
+
* the screen, click something by its visible name, type into a named field.
|
|
6
|
+
* There is deliberately no evaluate, no raw selector and no DOM access — a
|
|
7
|
+
* persona that can run JavaScript verifies outcomes no human could, which would
|
|
8
|
+
* make verdicts wrongly optimistic in exactly the way a blind persona makes them
|
|
9
|
+
* wrongly pessimistic.
|
|
10
|
+
*
|
|
11
|
+
* The handlers run in-process against the runner's live Playwright roots, so
|
|
12
|
+
* there is one browser and one page. Every call is recorded as an observation,
|
|
13
|
+
* because a judge cannot otherwise tell a real complaint from an invented one —
|
|
14
|
+
* including a failed one: `look`/`click`/`type` are all bounded by
|
|
15
|
+
* `ACTION_TIMEOUT_MS`, so a stuck element is caught and recorded rather than
|
|
16
|
+
* hanging the case with no evidence ever written.
|
|
17
|
+
*
|
|
18
|
+
* `click` and `type` also accept an `offLimits` list of names (e.g. the chat
|
|
19
|
+
* composer's own input/send/stop): a request naming one is refused, structurally,
|
|
20
|
+
* before the element is even looked up, instead of relying on an instruction the
|
|
21
|
+
* persona is free to ignore. This is what keeps "look and act freely, but talk by
|
|
22
|
+
* replying" (spec §3) an invariant rather than a suggestion.
|
|
23
|
+
*/
|
|
24
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
25
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
26
|
+
export const MCP_SERVER_NAME = 'page';
|
|
27
|
+
export const SNAPSHOT_CAP = 4000;
|
|
28
|
+
// Bounds every Playwright call the persona itself makes (look/click/type).
|
|
29
|
+
// Without this, an element Playwright calls visible/enabled/stable but stuck
|
|
30
|
+
// outside the viewport — the exact case a live run hit — waits with no ceiling
|
|
31
|
+
// of its own, wedging the case until the test runner's 15-minute timeout kills
|
|
32
|
+
// it with no diagnosis. 15s mirrors chat-driver.ts's SEND_TIMEOUT_MS: a person
|
|
33
|
+
// does not wait minutes for something to become clickable, and a timeout here
|
|
34
|
+
// must surface through the existing try/catch as a recorded observation
|
|
35
|
+
// instead of an unrecorded hang.
|
|
36
|
+
export const ACTION_TIMEOUT_MS = 15000;
|
|
37
|
+
export function truncate(text) {
|
|
38
|
+
return text.length <= SNAPSHOT_CAP ? text : text.slice(0, SNAPSHOT_CAP) + '…[truncated]';
|
|
39
|
+
}
|
|
40
|
+
const TOOLS = [
|
|
41
|
+
{
|
|
42
|
+
name: 'look',
|
|
43
|
+
description: 'Look at the screen. Returns what is currently visible, as an accessibility outline of the page. Use this before saying anything about what you can or cannot see.',
|
|
44
|
+
inputSchema: { type: 'object', properties: {} }
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'click',
|
|
48
|
+
description: 'Click something by the visible text or label on it, exactly as a person would point at it.',
|
|
49
|
+
inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'The visible text or accessible name of what to click' } }, required: ['name'] }
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'type',
|
|
53
|
+
description: 'Type into a field identified by its visible label.',
|
|
54
|
+
inputSchema: { type: 'object', properties: { name: { type: 'string' }, text: { type: 'string' } }, required: ['name', 'text'] }
|
|
55
|
+
}
|
|
56
|
+
];
|
|
57
|
+
export function createPagePerception(roots, opts = {}) {
|
|
58
|
+
const observations = [];
|
|
59
|
+
let turn = 0;
|
|
60
|
+
// Equality, not substring: the persona copies names verbatim out of the aria
|
|
61
|
+
// snapshot it just looked at, so an exact (trimmed, case-insensitive) match is
|
|
62
|
+
// enough to catch it — while substring matching on a short off-limits word
|
|
63
|
+
// like "Send" would also block an unrelated "Send report" button.
|
|
64
|
+
const offLimits = new Set((opts.offLimits ?? []).map(n => n.trim().toLowerCase()));
|
|
65
|
+
const isOffLimits = (name) => offLimits.has(name.trim().toLowerCase());
|
|
66
|
+
const OFF_LIMITS_RESULT = 'the composer is not yours to operate — reply with your message and the runner will send it for you';
|
|
67
|
+
const look = async () => {
|
|
68
|
+
const parts = [];
|
|
69
|
+
for (const { label, root } of roots) {
|
|
70
|
+
let snap = '';
|
|
71
|
+
try {
|
|
72
|
+
snap = await root.locator('body').ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
snap = `(could not read: ${err instanceof Error ? err.message : String(err)})`;
|
|
76
|
+
}
|
|
77
|
+
// Capped per root, not on the joined result: otherwise a large first
|
|
78
|
+
// root can consume the whole budget and a second root (e.g. an embedded
|
|
79
|
+
// `## chat panel`) disappears from the log entirely, with no marker
|
|
80
|
+
// hinting it was ever there.
|
|
81
|
+
parts.push(truncate(`## ${label}\n${snap}`));
|
|
82
|
+
}
|
|
83
|
+
return parts.join('\n\n');
|
|
84
|
+
};
|
|
85
|
+
const firstMatch = async (finders) => {
|
|
86
|
+
for (const find of finders) {
|
|
87
|
+
try {
|
|
88
|
+
const loc = find();
|
|
89
|
+
if (await loc.count() > 0)
|
|
90
|
+
return loc;
|
|
91
|
+
}
|
|
92
|
+
catch { /* a finder that throws simply does not match */ }
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
};
|
|
96
|
+
const click = async (name) => {
|
|
97
|
+
if (isOffLimits(name))
|
|
98
|
+
return OFF_LIMITS_RESULT;
|
|
99
|
+
for (const { root } of roots) {
|
|
100
|
+
const loc = await firstMatch([
|
|
101
|
+
() => root.getByRole('button', { name }).first(),
|
|
102
|
+
() => root.getByRole('link', { name }).first(),
|
|
103
|
+
() => root.getByText(name).first()
|
|
104
|
+
]);
|
|
105
|
+
if (loc) {
|
|
106
|
+
try {
|
|
107
|
+
await loc.click({ timeout: ACTION_TIMEOUT_MS });
|
|
108
|
+
return `clicked "${name}"`;
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
return `could not click "${name}": ${err instanceof Error ? err.message : String(err)}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return `could not find anything called "${name}" to click`;
|
|
116
|
+
};
|
|
117
|
+
const type = async (name, text) => {
|
|
118
|
+
if (isOffLimits(name))
|
|
119
|
+
return OFF_LIMITS_RESULT;
|
|
120
|
+
for (const { root } of roots) {
|
|
121
|
+
const loc = await firstMatch([
|
|
122
|
+
() => root.getByRole('textbox', { name }).first(),
|
|
123
|
+
() => root.getByLabel(name).first()
|
|
124
|
+
]);
|
|
125
|
+
if (loc) {
|
|
126
|
+
try {
|
|
127
|
+
await loc.fill(text, { timeout: ACTION_TIMEOUT_MS });
|
|
128
|
+
return `typed into "${name}"`;
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return `could not type into "${name}": ${err instanceof Error ? err.message : String(err)}`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return `could not find a field called "${name}"`;
|
|
136
|
+
};
|
|
137
|
+
const call = async (tool, args) => {
|
|
138
|
+
let result;
|
|
139
|
+
if (tool === 'look')
|
|
140
|
+
result = await look();
|
|
141
|
+
else if (tool === 'click')
|
|
142
|
+
result = await click(String(args.name ?? ''));
|
|
143
|
+
else if (tool === 'type')
|
|
144
|
+
result = await type(String(args.name ?? ''), String(args.text ?? ''));
|
|
145
|
+
else
|
|
146
|
+
result = `unknown tool: ${tool}`;
|
|
147
|
+
observations.push({ turn, tool, args, result });
|
|
148
|
+
return result;
|
|
149
|
+
};
|
|
150
|
+
const instance = new Server({ name: MCP_SERVER_NAME, version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
151
|
+
instance.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
152
|
+
instance.setRequestHandler(CallToolRequestSchema, async (req) => ({
|
|
153
|
+
content: [{ type: 'text', text: await call(req.params.name, (req.params.arguments ?? {})) }]
|
|
154
|
+
}));
|
|
155
|
+
return {
|
|
156
|
+
server: { type: 'sdk', name: MCP_SERVER_NAME, instance, alwaysLoad: true },
|
|
157
|
+
observations,
|
|
158
|
+
setTurn: (n) => { turn = n; },
|
|
159
|
+
toolNames: TOOLS.map(t => t.name),
|
|
160
|
+
call,
|
|
161
|
+
offLimits: opts.offLimits ?? []
|
|
162
|
+
};
|
|
163
|
+
}
|
package/persona.d.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import type { SimulationCase } from './types.ts';
|
|
2
|
+
import type { PagePerception } from './page-perception.ts';
|
|
2
3
|
export declare const DONE = "DONE";
|
|
4
|
+
export declare const PERSONA_MAX_TURNS = 25;
|
|
5
|
+
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.";
|
|
3
6
|
export declare function isDone(message: string): boolean;
|
|
4
|
-
export declare function personaSystemPrompt(c: SimulationCase): string;
|
|
7
|
+
export declare function personaSystemPrompt(c: SimulationCase, perceptionEnabled?: boolean, offLimitsActive?: boolean): string;
|
|
8
|
+
/**
|
|
9
|
+
* The SDK's `query`, narrowed to what the persona uses. Injectable (mirrors
|
|
10
|
+
* `BridgeQuery` in bridge/server.ts) so a test can observe the options actually
|
|
11
|
+
* handed over — including the perception wiring — without a network call or a
|
|
12
|
+
* live model.
|
|
13
|
+
*/
|
|
14
|
+
export type PersonaQuery = (typeof import('@anthropic-ai/claude-agent-sdk'))['query'];
|
|
5
15
|
export declare function personaPrompt(conversation: Array<{
|
|
6
16
|
role: string;
|
|
7
17
|
text: string;
|
|
@@ -9,4 +19,7 @@ export declare function personaPrompt(conversation: Array<{
|
|
|
9
19
|
export declare function nextUserMessage(c: SimulationCase, conversation: Array<{
|
|
10
20
|
role: string;
|
|
11
21
|
text: string;
|
|
12
|
-
}>, turnsLeft: number
|
|
22
|
+
}>, turnsLeft: number, opts?: {
|
|
23
|
+
perception?: PagePerception;
|
|
24
|
+
query?: PersonaQuery;
|
|
25
|
+
}): Promise<string>;
|
package/persona.js
CHANGED
|
@@ -9,8 +9,41 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { createNeutralCwd, isolationOptions } from "./isolation.js";
|
|
11
11
|
import { MISSING_SDK_MESSAGE, isMissingSdkError } from "./missing-sdk.js";
|
|
12
|
+
import { MCP_SERVER_NAME } from "./page-perception.js";
|
|
12
13
|
export const DONE = 'DONE';
|
|
13
14
|
let neutralCwd;
|
|
15
|
+
// The persona looks and acts before replying, so one turn is not enough:
|
|
16
|
+
// look → act → look → reply, with room to spare.
|
|
17
|
+
//
|
|
18
|
+
// Tuned from real runs, three times, and the number follows the shape of the
|
|
19
|
+
// work rather than a guess. It is the budget for ONE message: how many tool
|
|
20
|
+
// calls the person may make before answering.
|
|
21
|
+
//
|
|
22
|
+
// 6 was the cost of look/click/look/click/look — the most ordinary thing a
|
|
23
|
+
// person does on a multi-step page — leaving nothing for the reply.
|
|
24
|
+
//
|
|
25
|
+
// 12 was exactly the length of a guided workflow. In a run of the dataset
|
|
26
|
+
// creation case the assistant handed over the whole procedure in one message
|
|
27
|
+
// and the person executed it in one turn: click Create, choose the type, skip
|
|
28
|
+
// the init step, type a title, tick an option, continue — with a look between
|
|
29
|
+
// each, twelve calls of purposeful work and nothing wasted. The reply then had
|
|
30
|
+
// no budget left and the run was discarded.
|
|
31
|
+
//
|
|
32
|
+
// So a guided scenario costs roughly (steps × 2) + 1, and the assistant decides
|
|
33
|
+
// how many steps it hands over at once. 25 covers a full wizard driven in a
|
|
34
|
+
// single message, with the verification looks and the reply, and still stops a
|
|
35
|
+
// genuinely lost persona long before it could wander for minutes.
|
|
36
|
+
export const PERSONA_MAX_TURNS = 25;
|
|
37
|
+
export const PERCEPTION_INSTRUCTIONS = `You can look at the screen yourself with the look tool, and you can click and type
|
|
38
|
+
on the page. Before you say anything about what is or is not on the screen, look.
|
|
39
|
+
Never claim you cannot see something you have not looked for.`;
|
|
40
|
+
// Appended only when the caller actually configured createPagePerception's
|
|
41
|
+
// offLimits — otherwise nothing refuses the composer and this sentence would be
|
|
42
|
+
// a promise the harness does not keep (the persona types its message in itself,
|
|
43
|
+
// double-sending). See lib-sim/README.md, "Give the persona eyes".
|
|
44
|
+
const COMPOSER_OFF_LIMITS_INSTRUCTIONS = `The message box and its Send button will refuse you if you try to click or type into
|
|
45
|
+
them — that part of the page is not yours to operate. To talk to the assistant, just
|
|
46
|
+
reply with your message; the runner types and sends it for you.`;
|
|
14
47
|
export function isDone(message) {
|
|
15
48
|
if (!message)
|
|
16
49
|
return false;
|
|
@@ -29,8 +62,8 @@ export function isDone(message) {
|
|
|
29
62
|
// Only true if it is exactly DONE, not a sentence containing the word
|
|
30
63
|
return normalized === DONE;
|
|
31
64
|
}
|
|
32
|
-
export function personaSystemPrompt(c) {
|
|
33
|
-
|
|
65
|
+
export function personaSystemPrompt(c, perceptionEnabled = false, offLimitsActive = false) {
|
|
66
|
+
const lines = [
|
|
34
67
|
c.persona,
|
|
35
68
|
'',
|
|
36
69
|
`What you want: ${c.goal}`,
|
|
@@ -43,7 +76,13 @@ export function personaSystemPrompt(c) {
|
|
|
43
76
|
'',
|
|
44
77
|
'Reply with ONLY the message you would type next — no quotes, no narration, no stage directions.',
|
|
45
78
|
`When you have what you wanted, or you are convinced you will not get it, reply with exactly ${DONE} and nothing else.`
|
|
46
|
-
]
|
|
79
|
+
];
|
|
80
|
+
if (perceptionEnabled) {
|
|
81
|
+
lines.push('', PERCEPTION_INSTRUCTIONS);
|
|
82
|
+
if (offLimitsActive)
|
|
83
|
+
lines.push('', COMPOSER_OFF_LIMITS_INSTRUCTIONS);
|
|
84
|
+
}
|
|
85
|
+
return lines.join('\n');
|
|
47
86
|
}
|
|
48
87
|
export function personaPrompt(conversation, turnsLeft) {
|
|
49
88
|
if (conversation.length === 0)
|
|
@@ -60,29 +99,51 @@ export function personaPrompt(conversation, turnsLeft) {
|
|
|
60
99
|
`Write your next message, or ${DONE} if you are finished.${warning}`
|
|
61
100
|
].join('\n');
|
|
62
101
|
}
|
|
63
|
-
export async function nextUserMessage(c, conversation, turnsLeft) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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'));
|
|
102
|
+
export async function nextUserMessage(c, conversation, turnsLeft, opts) {
|
|
103
|
+
let runQuery;
|
|
104
|
+
if (opts?.query) {
|
|
105
|
+
runQuery = opts.query;
|
|
71
106
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
107
|
+
else {
|
|
108
|
+
// Loaded here, not at module top level, so importing the package barrel
|
|
109
|
+
// never requires the Agent SDK — it is an optional peer, and a consumer who
|
|
110
|
+
// only wants the harness primitives must not pay for it. This is the only
|
|
111
|
+
// place in the exported surface that reaches for it at runtime.
|
|
112
|
+
try {
|
|
113
|
+
({ query: runQuery } = await import('@anthropic-ai/claude-agent-sdk'));
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (isMissingSdkError(err))
|
|
117
|
+
throw new Error(MISSING_SDK_MESSAGE);
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
76
120
|
}
|
|
77
121
|
neutralCwd ??= createNeutralCwd();
|
|
78
122
|
let text = '';
|
|
79
|
-
for await (const msg of
|
|
123
|
+
for await (const msg of runQuery({
|
|
80
124
|
prompt: personaPrompt(conversation, turnsLeft),
|
|
81
125
|
options: {
|
|
82
126
|
...isolationOptions(neutralCwd),
|
|
83
127
|
model: process.env.SIM_USER_MODEL ?? 'haiku',
|
|
84
|
-
systemPrompt: personaSystemPrompt(c),
|
|
85
|
-
|
|
128
|
+
systemPrompt: personaSystemPrompt(c, !!opts?.perception, !!opts?.perception?.offLimits.length),
|
|
129
|
+
// Unconditional: a caller with no perception registers no mcpServers, so
|
|
130
|
+
// the persona has no tool to call and the loop still ends after the one
|
|
131
|
+
// assistant turn a blind persona always took — the higher cap only ever
|
|
132
|
+
// matters once look/click/type are actually wired in below.
|
|
133
|
+
maxTurns: PERSONA_MAX_TURNS,
|
|
134
|
+
...(opts?.perception
|
|
135
|
+
? {
|
|
136
|
+
// page-perception.ts deliberately builds the LOW-LEVEL MCP `Server`
|
|
137
|
+
// (server/index.js), not the high-level `McpServer` helper the SDK's
|
|
138
|
+
// `McpServerConfig` type expects — the low-level API accepts raw JSON
|
|
139
|
+
// Schema for tool inputs, while `McpServer` demands Zod (the same
|
|
140
|
+
// choice bridge/tool-server.ts makes, cast at the same boundary in
|
|
141
|
+
// bridge/server.ts). The two classes are structurally unrelated, so
|
|
142
|
+
// no tighter typing of `instance` would remove this cast.
|
|
143
|
+
mcpServers: { [MCP_SERVER_NAME]: opts.perception.server },
|
|
144
|
+
allowedTools: opts.perception.toolNames.map(n => `mcp__${MCP_SERVER_NAME}__${n}`)
|
|
145
|
+
}
|
|
146
|
+
: {})
|
|
86
147
|
}
|
|
87
148
|
})) {
|
|
88
149
|
if (msg.type === 'assistant') {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
2
|
+
name: agents-sim
|
|
3
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
4
|
---
|
|
5
5
|
|
|
@@ -11,12 +11,16 @@ transcript.
|
|
|
11
11
|
|
|
12
12
|
## Before you start
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Four things must be true, and each fails confusingly if it is not:
|
|
15
15
|
|
|
16
16
|
1. The dev stack is up — `bash dev/status.sh`.
|
|
17
17
|
2. The workspace packages are built — `ls lib-vue/*.js lib-vuetify/*.js`. If they
|
|
18
18
|
are missing, e2e-style runs fail with "element not found".
|
|
19
|
-
3.
|
|
19
|
+
3. `lib-sim` is built — `npm -w @data-fair/lib-agents-sim run build`.
|
|
20
|
+
`simulations/` imports it by package name, not by relative path, so a stale
|
|
21
|
+
build silently runs the OLD code and still reports the run valid — the exact
|
|
22
|
+
failure mode this subsystem exists to catch.
|
|
23
|
+
4. The bridge is running — `npm run dev-bridge`. The runner checks this and says so.
|
|
20
24
|
|
|
21
25
|
Ask the user to start anything that is down. Never start or stop dev processes yourself.
|
|
22
26
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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 /
|
|
3
|
+
description: Judge one scenario simulation transcript and return a JSON verdict. Use when asked to verdict a simulation run produced by the /agents-sim skill.
|
|
4
4
|
tools: Read
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -15,6 +15,15 @@ The transcript holds:
|
|
|
15
15
|
- `gateway` — every request the page made, carrying the tools it offered and the
|
|
16
16
|
tool calls the assistant actually made
|
|
17
17
|
- `consoleErrors` — browser errors during the run
|
|
18
|
+
- `observations` — what the person actually looked at and did, recorded per turn:
|
|
19
|
+
`{ turn, tool, args, result }`. `look` returns the accessibility outline of the
|
|
20
|
+
screen at that moment.
|
|
21
|
+
|
|
22
|
+
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 HARNESS
|
|
24
|
+
fault, not product friction — say so plainly in `notes` and do not count it as a
|
|
25
|
+
friction point. This has happened: a run once had the person insist a panel was
|
|
26
|
+
closed having never looked, and the judge reported it as a product failure.
|
|
18
27
|
|
|
19
28
|
`gateway` records what the browser SENT to the server, and each request carries
|
|
20
29
|
the whole conversation so far — so the assistant's FINAL reply of a conversation
|
package/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* The shapes a host repo needs to write cases and read evidence.
|
|
3
3
|
*/
|
|
4
4
|
import type { GatewayExchange } from './gateway-capture.ts';
|
|
5
|
+
import type { Observation } from './page-perception.ts';
|
|
5
6
|
export type SimulationCase = {
|
|
6
7
|
/** Evidence files are named after this; keep it filesystem-safe. */
|
|
7
8
|
name: string;
|
|
@@ -24,6 +25,7 @@ export type Transcript = {
|
|
|
24
25
|
}>;
|
|
25
26
|
gateway: GatewayExchange[];
|
|
26
27
|
consoleErrors: string[];
|
|
28
|
+
observations: Observation[];
|
|
27
29
|
};
|
|
28
30
|
export type RunSidecar = {
|
|
29
31
|
case: string;
|