Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions docs/architecture/host-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,23 @@ a pending event — not merely "a host store exists"): the tool's own descriptio
the model to call it, so advertising it to a page that has never published anything
would only ever end in a bounded, pointless dead turn. Re-checked on every tool-set
rebuild (turn start and every mid-turn rebuild), so a page that starts publishing
mid-conversation gains the tool at the next one. `{ expecting, timeoutSeconds? }`
(default 300s, max 600s). It resolves on **what the person did, not on the next event whatever it
mid-conversation gains the tool at the next one. `{ message, expecting, timeoutSeconds? }`
(default 300s, max 600s). `message` is the model's reply to the person — what is ready and
what to press — and the chat shows it after the step's own text, unless that text already
says it (`applyStreamPart`); `expecting` is only the status label. Before `message` existed, models
read the « En attente : … » label as their message and handed buttons over in silence.

**Speaking during a wait** takes the turn back: `sendMessage` aborts the waiting turn and starts
a new one. An aborted turn never reaches `result.response`, which is where a turn's messages
normally enter history, so the turn's finished steps are tracked as they complete and committed
(`commitRunningTurn`) before the new turn pushes its user message; the step still open — the one
holding the wait — is recorded with a result saying it was interrupted (`interrupted-turn.ts`).
Stop commits the same way, with a result saying the reply was stopped. The open step is reset on
the `finish-step` part the loop reads, not in `onStepFinish`: the SDK runs that callback on its
side of the stream, possibly before the loop has read the step's parts, and any call already in
the finished steps is dropped from the open one so no call id reaches history twice. A result
that did arrive in the open step goes through its tool's `toModelOutput`, as the SDK would. Without this, judged runs sent the model the person's two messages
with nothing between, and the assistant denied work it had done and redid it. It resolves on **what the person did, not on the next event whatever it
is**. The store already separates two kinds of event: an unkeyed transition is something
that happened, keyed state is what is true now — and state refreshes for many reasons,
including the assistant's own action finishing late. A wait resolves on a transition, or on
Expand Down
13 changes: 13 additions & 0 deletions lib-sim/chat-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export const WAIT_SETTLE_MS = 500
* words interpolated into a translated string, so any text match would be both
* locale-dependent and at the mercy of what the assistant wrote.
*/
/** The chat empties its composer as it takes a message, so this only has to cover a busy page. */
export const SENT_TIMEOUT_MS = 5000

export const WAITING_SELECTOR = '[data-testid="chat-activity"][data-activity="waiting"]'

export function createChatDriver (root: ChatRoot, opts: { locale?: ChatDriverLocale } = {}) {
Expand All @@ -91,6 +94,16 @@ export function createChatDriver (root: ChatRoot, opts: { locale?: ChatDriverLoc
const send = root.getByRole('button', { name: strings.send })
await send.waitFor({ state: 'visible', timeout: opts.readyTimeoutMs ?? SEND_TIMEOUT_MS })
await send.click({ timeout: SEND_TIMEOUT_MS })
// A click that resolves is not a message sent. A judged run's persona "wrote"
// three messages this way while a wait was armed: each stayed in the box, no
// request followed, and the run still counted nine turns. The chat empties its
// composer when it takes a message, so that is the proof asked for here.
const composer = root.getByPlaceholder(strings.input)
const deadline = Date.now() + SENT_TIMEOUT_MS
while (await composer.inputValue({ timeout: SEND_TIMEOUT_MS }) !== '') {
if (Date.now() > deadline) throw new Error('the message was still in the composer after Send was clicked: it was never sent')
await new Promise(resolve => setTimeout(resolve, 100))
}
}
try {
await fillAndSend()
Expand Down
2 changes: 1 addition & 1 deletion lib-sim/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@data-fair/lib-agents-sim",
"version": "0.7.0",
"version": "0.8.0",
"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.",
"main": "index.js",
"type": "module",
Expand Down
118 changes: 109 additions & 9 deletions lib-sim/page-perception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export const SNAPSHOT_CAP = 4000
// instead of an unrecorded hang.
export const ACTION_TIMEOUT_MS = 15000

export type PerceptionRoot = { label: string, root: ChatRoot }
/** `cap` overrides SNAPSHOT_CAP for this root: a dense page can deserve more than a chat panel. */
export type PerceptionRoot = { label: string, root: ChatRoot, cap?: number }
export type Observation = { turn: number, tool: string, args: unknown, result: string }

export type PagePerception = {
Expand All @@ -65,12 +66,111 @@ export type PagePerception = {
* point before and after the click, and whether the dialog ever opened was not
* decidable from the record — the judge had to say so instead of ruling.
*/
export function truncate (text: string): string {
if (text.length <= SNAPSHOT_CAP) return text
const marker = '\n…[truncated]\n'
const head = Math.floor(SNAPSHOT_CAP * 0.6)
const tail = SNAPSHOT_CAP - head
return text.slice(0, head) + marker + text.slice(-tail)
//
// On line boundaries, because a cut inside a line reads as the whole line. A judged
// run's cut fell inside the value of a filled textbox: the persona read
// `textbox "Termes de recherche associés": gymnase, salle de sport` as the field's
// entire content, told the assistant its 14 terms were missing, and never saved.
// A line is shown whole or not at all; only a single line longer than the whole
// budget is cut, and says so.
export function truncate (text: string, cap: number = SNAPSHOT_CAP): string {
if (text.length <= cap) return text
const lines = text.split('\n')
const headBudget = Math.floor(cap * 0.6)
const tailBudget = cap - headBudget
let used = 0
let head = 0
while (head < lines.length && used + lines[head].length + 1 <= headBudget) used += lines[head++].length + 1
used = 0
let tail = 0
while (tail < lines.length - head && used + lines[lines.length - 1 - tail].length + 1 <= tailBudget) used += lines[lines.length - 1 - (tail++)].length + 1
const headPart = head ? lines.slice(0, head) : [lines[0].slice(0, headBudget) + ' …[line cut]']
const tailPart = tail ? lines.slice(lines.length - tail) : [lines.length - head > 1 ? '[line cut]… ' + lines[lines.length - 1].slice(-tailBudget) : '']
const hidden = lines.length - Math.max(head, 1) - Math.max(tail, 1)
return [...headPart, `…[truncated: ${Math.max(hidden, 0)} lines not shown]`, ...tailPart].join('\n')
}

type SnapNode = { line: string, children: SnapNode[], raw?: boolean }

/** Rows a person takes in at a glance; the rest are counted, not listed. */
export const TABLE_ROWS_KEPT = 20

/**
* Drop what repeats or says nothing, before the cap has to cut something that matters.
*
* The cap alone hid a whole form: on a dataset page the table's cells and a rich-text
* toolbar filled the head budget, the fields below the Description editor landed in the
* cut, and the persona told the assistant a field it had just filled did not exist.
* - a row keeps its name, which already spells its cells, and loses the cells;
* - a table lists TABLE_ROWS_KEPT rows and counts the others;
* - a toolbar becomes one line of button names;
* - unnamed images, link targets, separators and "|" dividers go.
*/
export function pruneSnapshot (snapshot: string): string {
const root: SnapNode = { line: '', children: [] }
const stack: { indent: number, node: SnapNode }[] = [{ indent: -1, node: root }]
for (const raw of snapshot.split('\n')) {
const m = raw.match(/^(\s*)- (.*)$/)
if (!m) {
// a continuation line of a multi-line value: keep it with the current node
const last = stack[stack.length - 1].node
if (last !== root) last.line += '\n' + raw
else if (raw.trim()) root.children.push({ line: raw, children: [], raw: true })
continue
}
const indent = m[1].length
while (stack[stack.length - 1].indent >= indent) stack.pop()
const node = { line: m[2], children: [] }
stack[stack.length - 1].node.children.push(node)
stack.push({ indent, node })
}

const isNoise = (line: string) => line === 'img' || line === 'separator' || line === 'text: "|"' || line.startsWith('/url:')
const isCell = (line: string) => /^(cell|gridcell|columnheader|rowheader)\b/.test(line)
const buttonName = (line: string) => line.match(/^button "(.*)"/)?.[1]

const prune = (node: SnapNode): SnapNode | null => {
if (node.raw) return node
if (isNoise(node.line)) return null
if (node.line.startsWith('toolbar')) {
const names: string[] = []
const collect = (n: SnapNode) => { const b = buttonName(n.line); if (b) names.push(b); n.children.forEach(collect) }
node.children.forEach(collect)
return { line: `toolbar: ${names.join(', ')}`, children: [] }
}
if (/^row "/.test(node.line) && node.children.every(c => isCell(c.line))) {
return { line: node.line.replace(/:$/, ''), children: [] }
}
let children = node.children.map(prune).filter((c): c is SnapNode => !!c)
if (/^(table|grid|treegrid)\b/.test(node.line)) children = capRows(children)
const line = children.length ? node.line : node.line.replace(/:$/, '')
return { line, children }
}

// Rows sit under rowgroups; count them across the whole table, header row included.
const capRows = (groups: SnapNode[]): SnapNode[] => {
let seen = 0
let dropped = 0
const walk = (nodes: SnapNode[]): SnapNode[] => nodes.flatMap(n => {
if (/^row\b/.test(n.line)) {
seen++
if (seen > TABLE_ROWS_KEPT + 1) { dropped++; return [] }
return [n]
}
const children = walk(n.children)
return [{ line: children.length ? n.line : n.line.replace(/:$/, ''), children }]
})
const kept = walk(groups)
return dropped ? [...kept, { line: `text: … ${dropped} more rows`, children: [] }] : kept
}

const out: string[] = []
const write = (n: SnapNode, depth: number) => {
out.push(n.raw ? n.line : `${' '.repeat(depth)}- ${n.line}`)
n.children.forEach(c => write(c, depth + 1))
}
root.children.map(prune).forEach(n => { if (n) write(n, 0) })
return out.join('\n')
}

const TOOLS = [
Expand Down Expand Up @@ -105,7 +205,7 @@ export function createPagePerception (roots: PerceptionRoot[], opts: { offLimits

const look = async () => {
const parts: string[] = []
for (const { label, root } of roots) {
for (const { label, root, cap } of roots) {
let snap = ''
try { snap = await root.locator('body').ariaSnapshot({ timeout: ACTION_TIMEOUT_MS }) } catch (err) {
snap = `(could not read: ${err instanceof Error ? err.message : String(err)})`
Expand All @@ -114,7 +214,7 @@ export function createPagePerception (roots: PerceptionRoot[], opts: { offLimits
// root can consume the whole budget and a second root (e.g. an embedded
// `## chat panel`) disappears from the log entirely, with no marker
// hinting it was ever there.
parts.push(truncate(`## ${label}\n${snap}`))
parts.push(truncate(`## ${label}\n${pruneSnapshot(snap)}`, cap))
}
return parts.join('\n\n')
}
Expand Down
27 changes: 23 additions & 4 deletions lib-sim/persona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { createNeutralCwd, isolationOptions } from './isolation.ts'
import { MISSING_SDK_MESSAGE, isMissingSdkError } from './missing-sdk.ts'
import type { SimulationCase } from './types.ts'
import type { PagePerception } from './page-perception.ts'
import type { PagePerception, Observation } from './page-perception.ts'
import { MCP_SERVER_NAME } from './page-perception.ts'
import type { McpServerConfig } from '@anthropic-ai/claude-agent-sdk'

Expand Down Expand Up @@ -64,7 +64,9 @@ export const PERSONA_MAX_TURNS = 25

export const PERCEPTION_INSTRUCTIONS = `You can look at the screen yourself with the look tool, and you can click and type
on the page. Before you say anything about what is or is not on the screen, look.
Never claim you cannot see something you have not looked for.`
Never claim you cannot see something you have not looked for.
Your tools are the only way you act on the page: never say you did something (reload, scroll, save…)
that is not in the list of what you did.`

// Appended only when the caller actually configured createPagePerception's
// offLimits — otherwise nothing refuses the composer and this sentence would be
Expand Down Expand Up @@ -134,17 +136,34 @@ export function personaSystemPrompt (c: SimulationCase, perceptionEnabled = fals
*/
export type PersonaQuery = (typeof import('@anthropic-ai/claude-agent-sdk'))['query']

export function personaPrompt (conversation: Array<{ role: string, text: string }>, turnsLeft: number): string {
/**
* What the persona did on the page, told back to it. Each persona turn is a fresh query
* whose only memory is the chat transcript, so it could not tell what it had done from
* what the assistant had told it to do: judged runs had it say « J'ai actualisé la
* page » after being told to reload, with no reload among its actions.
*/
export function actionRecap (observations: Observation[]): string | null {
const acts = observations.filter(o => o.tool !== 'look').slice(-10).map(o => {
const args = (o.args ?? {}) as { name?: unknown, text?: unknown }
const what = o.tool === 'click' ? `clicked "${args.name}"` : o.tool === 'type' ? `typed "${args.text}" into "${args.name}"` : o.tool
return `- ${what} → ${o.result.slice(0, 120)}`
})
return acts.length ? ['What you have done on the page so far (nothing else):', ...acts].join('\n') : null
}

export function personaPrompt (conversation: Array<{ role: string, text: string }>, turnsLeft: number, observations: Observation[] = []): string {
if (conversation.length === 0) return 'Write your first message to the assistant.'
const transcript = conversation.map(m => `${m.role === 'user' ? 'you' : 'assistant'}: ${m.text}`).join('\n\n')
const warning = turnsLeft <= 1
? '\n\nThis is your last message. If you already have what you needed, reply ' + DONE + '.'
: ''
const recap = actionRecap(observations)
return [
'The conversation so far:',
'',
transcript,
'',
...(recap ? [recap, ''] : []),
`Write your next message, or ${DONE} if you are finished.${warning}`
].join('\n')
}
Expand Down Expand Up @@ -174,7 +193,7 @@ export async function nextUserMessage (
neutralCwd ??= createNeutralCwd()
let text = ''
for await (const msg of runQuery({
prompt: personaPrompt(conversation, turnsLeft),
prompt: personaPrompt(conversation, turnsLeft, opts?.perception?.observations),
options: {
...isolationOptions(neutralCwd),
model: resolveUserModel(),
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions simulations/cases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,19 @@ export const cases: LocalCase[] = [
persona: 'You are an office worker using an internal tool for the first time. You are not technical, you describe what you want in plain words, and you press buttons yourself when someone tells you which one. You expect to be told what happened without having to ask.',
goal: 'You want a list called "Weekly groceries" set up. You will press the Create button yourself when the assistant says it is ready. Once you have, you want to ask the assistant what you are looking at now, and you expect it to already know what was created rather than asking you.',
maxTurns: 6
},
// Speaking during a wait: the person writes instead of pressing the button they were
// handed, which aborts the waiting turn and starts a new one. That once dropped the
// whole interrupted turn from history — the model saw two user messages and nothing
// between, denied having prepared anything and rebuilt it. What is under test is the
// assistant answering from what it already did and handing the same button back.
// The goal's last sentence catches an assistant that answers and never waits again:
// one did, and never learned the list it had prepared was created.
{
name: 'speak-during-wait',
route: '/agents/_dev/chat-workflow',
persona: 'You are an office worker using an internal tool for the first time. You are not technical and you describe what you want in plain words. You are careful: before pressing a button that creates something, you always first ask a question in the chat to double-check, and you press it only once you have the answer. You never claim to have pressed a button you did not press.',
goal: 'You want a list called "Team lunch" set up. When the assistant says it is ready, you first ask it in the chat what the list will be called, because you are not sure it got the name right; once it answers, you press the Create button yourself. After pressing it you wait: you expect the assistant to tell you on its own that the list was created, without you having to ask or report it.',
maxTurns: 6
}
]
22 changes: 21 additions & 1 deletion simulations/simulate.sim.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,29 @@ for (const simCase of selected) {
{ offLimits: [strings.input, strings.send, strings.stop, strings.reset] }
)

// Set when the assistant ended a turn by declaring wait_for_user_action rather
// than by finishing: it handed control to the person, who only exists inside
// nextUserMessage — so the next pass is where they act on it.
let handedOver = false

for (let i = 0; i < simCase.maxTurns; i++) {
perception.setTurn(i + 1)
const message = await nextUserMessage(simCase, conversation, simCase.maxTurns - i, { perception })
if (handedOver) {
// The pass above was the person's chance to act on the wait. If they took it,
// the wait resolved and the assistant is finishing the turn it paused: let it,
// rather than speaking over it or ending the run under it. Ignored, the wait is
// still armed and this returns 'waiting' at once.
handedOver = (await chat.waitForTurn()) === 'waiting'
const resumed = await chat.readConversation()
const changed = resumed.length !== conversation.length
conversation.length = 0
conversation.push(...resumed)
// A person who acted and stopped has not seen the reply their action caused:
// a judged run ended on the click, and whether the assistant noticed the
// creation was never on record. Let them read it before they decide.
if (changed && isDone(message)) continue
}
if (isDone(message)) break
if (message === '') {
// Distinct from a real stop: the persona subprocess produced no text
Expand All @@ -91,7 +111,7 @@ for (const simCase of selected) {
break
}
await chat.sendMessage(message)
await chat.waitForTurn()
handedOver = (await chat.waitForTurn()) === 'waiting'
// Read first, then replace: clearing up front meant a throw from
// readConversation left the transcript empty, losing every prior turn.
const read = await chat.readConversation()
Expand Down
Loading
Loading