@e2e-dev/decision 0.1.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.
@@ -0,0 +1,223 @@
1
+ /** TypeSafe's per-question choice limit; the AI SDK itself has none. */
2
+ const MAX_CHOICES = 255;
3
+ const tappable = new Set([
4
+ 'button',
5
+ 'link',
6
+ 'menuitem',
7
+ 'menuitemcheckbox',
8
+ 'menuitemradio',
9
+ 'tab',
10
+ 'option',
11
+ 'treeitem',
12
+ ]);
13
+ const typable = new Set(['textbox', 'searchbox', 'spinbutton', 'combobox']);
14
+ const secretTypable = new Set(['textbox', 'searchbox', 'combobox']);
15
+ const checkable = new Set(['checkbox', 'radio', 'switch']);
16
+ /** Builds the element table and bound targets from the newest observation. */
17
+ export function actionSpace(ctx, observation, canType) {
18
+ const verbs = ctx.target.verbs;
19
+ const rows = [];
20
+ const pageText = [];
21
+ /** Walks the tree, collecting interactive rows and page text. */
22
+ const visit = (node, underNativeSelect) => {
23
+ if (node.states?.disabled !== true && node.states?.hidden !== true) {
24
+ const operations = [];
25
+ const role = node.role ?? '';
26
+ const password = node.inputPurpose === 'password' || node.states?.secure === true;
27
+ const nativeSelect = role === 'combobox' && (node.children ?? []).some((child) => child.role === 'option');
28
+ const childSelect = underNativeSelect || nativeSelect;
29
+ const checkedRadio = role === 'radio' && node.states?.checked === true;
30
+ if (verbs.has('tap') && tappable.has(role) && !checkable.has(role) && !(role === 'option' && childSelect)) {
31
+ operations.push('tap');
32
+ }
33
+ if (typable.has(role) && !nativeSelect) {
34
+ if (!password && verbs.has('type') && canType)
35
+ operations.push('type');
36
+ if (verbs.has('typeSecret') && secretTypable.has(role) && ctx.step.secrets.length > 0) {
37
+ operations.push('typeSecret');
38
+ }
39
+ if (!password && verbs.has('press'))
40
+ operations.push('submit');
41
+ }
42
+ if (verbs.has('select') && nativeSelect)
43
+ operations.push('select');
44
+ // `check` toggles, so a checked radio would be unchecked: offer radios only when unchecked.
45
+ if (verbs.has('check') && checkable.has(role) && !checkedRadio)
46
+ operations.push('check');
47
+ if (operations.length > 0) {
48
+ rows.push({ node, operations, inViewport: intersects(node, observation.viewport) });
49
+ }
50
+ else {
51
+ const line = pageLine(node);
52
+ // A checked radio leaves the table, so its state rides on the page text.
53
+ if (line !== '')
54
+ pageText.push(checkedRadio ? `${line} (checked)` : line);
55
+ }
56
+ }
57
+ for (const child of node.children ?? [])
58
+ visit(child, underNativeSelect || node.role === 'combobox');
59
+ };
60
+ visit(observation.tree, false);
61
+ // Viewport nodes first, then tree order; drop the rest past the cap so
62
+ // the model can scroll to them, and never block on a large screen.
63
+ const ordered = [...rows]
64
+ .map((row, order) => ({ row, order }))
65
+ .toSorted((a, b) => Number(b.row.inViewport) - Number(a.row.inViewport) || a.order - b.order)
66
+ .map(({ row }) => row);
67
+ const omitted = Math.max(0, ordered.length - MAX_CHOICES);
68
+ const kept = ordered.slice(0, MAX_CHOICES);
69
+ const targets = new Map();
70
+ let selectCount = 0;
71
+ const elements = kept.map((row, position) => {
72
+ const index = String(position + 1);
73
+ const label = clip(nodeLabel(row.node), 120);
74
+ for (const operation of row.operations) {
75
+ if (operation === 'select') {
76
+ const options = new Map();
77
+ for (const [optionIndex, child] of (row.node.children ?? []).entries()) {
78
+ if (child.role !== 'option')
79
+ continue;
80
+ // An option the page hides or disables is no choice: the native
81
+ // select cannot perform it, so the model must never see it.
82
+ if (child.states?.hidden === true || child.states?.disabled === true)
83
+ continue;
84
+ // Every select shares one target group and one question, so options
85
+ // stop at the per-question cap. They stay out of `omitted`: scrolling
86
+ // never reveals an option the cap dropped.
87
+ if (selectCount >= MAX_CHOICES)
88
+ continue;
89
+ const key = `${index}:${optionIndex}`;
90
+ const optionLabel = child.name ?? child.text ?? '';
91
+ options.set(key, {
92
+ description: `select option ${JSON.stringify(optionLabel)} in ${label} [${row.node.id}]`,
93
+ optionLabel,
94
+ run: () => ctx.actions.select({ id: row.node.id }, optionLabel),
95
+ });
96
+ selectCount += 1;
97
+ }
98
+ if (options.size > 0) {
99
+ const group = targets.get('select') ?? new Map();
100
+ for (const [key, target] of options)
101
+ group.set(key, target);
102
+ targets.set('select', group);
103
+ }
104
+ }
105
+ else {
106
+ const key = index;
107
+ const target = bind(row.node, operation, label, ctx);
108
+ const group = targets.get(operation) ?? new Map();
109
+ group.set(key, target);
110
+ targets.set(operation, group);
111
+ }
112
+ }
113
+ return {
114
+ index,
115
+ role: row.node.role ?? '',
116
+ label,
117
+ ...(row.node.value === undefined ? {} : { value: row.node.value }),
118
+ ...(row.node.states?.checked === undefined ? {} : { checked: row.node.states.checked }),
119
+ ...(row.node.states?.expanded === undefined ? {} : { expanded: row.node.states.expanded }),
120
+ operations: row.operations,
121
+ };
122
+ });
123
+ const controls = new Map();
124
+ if (verbs.has('scroll')) {
125
+ controls.set('scroll_up', { description: 'scroll viewport up', run: () => ctx.actions.scroll('up') });
126
+ controls.set('scroll_down', { description: 'scroll viewport down', run: () => ctx.actions.scroll('down') });
127
+ }
128
+ if (verbs.has('back')) {
129
+ controls.set('back', { description: 'back one step in history', run: () => ctx.actions.back() });
130
+ }
131
+ return {
132
+ elements,
133
+ targets,
134
+ controls,
135
+ omitted,
136
+ pageText: clip(pageText.join('\n'), 6000),
137
+ fingerprint: fingerprint(observation.path ?? '', observation.tree, observation.viewport),
138
+ };
139
+ }
140
+ /** Binds one element operation to the runner's actions. */
141
+ function bind(node, operation, label, ctx) {
142
+ const target = { id: node.id };
143
+ const where = `${label} [${node.id}]`;
144
+ switch (operation) {
145
+ case 'tap':
146
+ return { description: `tap ${where}`, run: () => ctx.actions.tap(target) };
147
+ case 'type':
148
+ return { description: `type into ${where}`, run: (argument = '') => ctx.actions.type(target, argument) };
149
+ case 'typeSecret':
150
+ return { description: `typeSecret into ${where}`, run: (argument = '') => ctx.actions.typeSecret(target, argument) };
151
+ case 'submit':
152
+ return { description: `submit ${where}`, run: () => ctx.actions.press(target, 'Enter') };
153
+ case 'check':
154
+ return { description: `check ${where}`, run: () => ctx.actions.check(target, !(node.states?.checked ?? false)) };
155
+ case 'select': throw new Error('select binds per option, not per element');
156
+ }
157
+ }
158
+ /** Label the model reads: name, placeholder, or text. */
159
+ function nodeLabel(node) {
160
+ return node.name ?? node.attributes?.['placeholder'] ?? node.text ?? '';
161
+ }
162
+ /**
163
+ * One page-text line for a non-interactive node. A named node keeps its text
164
+ * when the two differ, as the runner's `formatNode` does: a status named
165
+ * "Greeting" whose text says "Welcome back, admin!" is the evidence a
166
+ * completion check needs, and the name alone hides it.
167
+ */
168
+ function pageLine(node) {
169
+ const name = (node.name ?? '').trim();
170
+ const text = (node.text ?? '').replace(/\s+/g, ' ').trim();
171
+ if (name === '')
172
+ return text === '' ? (node.attributes?.['placeholder'] ?? '').trim() : text;
173
+ return text === '' || text === name ? name : `${name} text=${JSON.stringify(text)}`;
174
+ }
175
+ /** Whether the node's box meets the viewport. Nodes without a box stay in tree order. */
176
+ function intersects(node, viewport) {
177
+ const rect = node.rect;
178
+ if (rect === undefined)
179
+ return false;
180
+ return rect.x < viewport.width && rect.y < viewport.height && rect.x + rect.width > 0 && rect.y + rect.height > 0;
181
+ }
182
+ function clip(text, limit) {
183
+ return text.length > limit ? text.slice(0, limit) : text;
184
+ }
185
+ /**
186
+ * Stable hash of the path, the tree content with node ids removed, and
187
+ * whether each node meets the viewport. Ids change on every capture, so they
188
+ * stay out. In-view membership lets a scroll that brings other nodes into
189
+ * view count as progress, while one that moves nothing (the page bottom)
190
+ * still reads as unchanged; raw coordinates stay out so small layout shifts
191
+ * do not count.
192
+ */
193
+ function fingerprint(path, tree, viewport) {
194
+ let hash = 2166136261;
195
+ const feed = (text) => {
196
+ for (let index = 0; index < text.length; index += 1) {
197
+ hash ^= text.charCodeAt(index);
198
+ hash = Math.imul(hash, 16777619);
199
+ }
200
+ };
201
+ feed(path);
202
+ feed('\0');
203
+ const visit = (node) => {
204
+ feed(node.role ?? '');
205
+ feed('\0');
206
+ feed(node.name ?? '');
207
+ feed('\0');
208
+ feed(node.text ?? '');
209
+ feed('\0');
210
+ feed(node.value ?? '');
211
+ feed('\0');
212
+ feed(JSON.stringify(node.states ?? null));
213
+ feed('\0');
214
+ feed(JSON.stringify(node.attributes ?? null));
215
+ feed('\0');
216
+ feed(intersects(node, viewport) ? 'v' : '-');
217
+ feed('\0');
218
+ for (const child of node.children ?? [])
219
+ visit(child);
220
+ };
221
+ visit(tree);
222
+ return (hash >>> 0).toString(36);
223
+ }
@@ -0,0 +1,4 @@
1
+ import type { StepExecutor } from 'e2e';
2
+ import type { DecisionExecutorOptions } from './types.ts';
3
+ /** Builds a step executor that acts through a decision model and an optional text model. */
4
+ export declare function decisionExecutor(options: DecisionExecutorOptions): StepExecutor;
@@ -0,0 +1,366 @@
1
+ import { AgentError, isAgentError } from 'e2e/agent';
2
+ import { ConfigurationError } from 'e2e/engine';
3
+ import { decide, requireDecide } from './decide.js';
4
+ import { actionSpace } from './elements.js';
5
+ import { assertionRequest, completionRequest, decisionRequest, elementRecords, nonSecretParams, targetKeyIndex, } from './questions.js';
6
+ import { fieldText } from './text.js';
7
+ /** Error codes the runtime owns: rethrown untouched, never absorbed as history. */
8
+ const RUNTIME_CODES = new Set(['STEP_BUDGET_EXHAUSTED', 'STEP_TIMEOUT', 'CANCELLED']);
9
+ /** The handle in the runner's secret-fill summary: `fill secret "<json string>"` at the start. */
10
+ const SECRET_FILL = /^fill secret "(?:[^"\\]|\\.)*"/;
11
+ /** Builds a step executor that acts through a decision model and an optional text model. */
12
+ export function decisionExecutor(options) {
13
+ if (typeof options !== 'object' || options === null) {
14
+ throw new ConfigurationError('INVALID_CONFIG', `decisionExecutor() takes an options object; ${EXAMPLE}`);
15
+ }
16
+ requireDecide();
17
+ checkModel(options.model);
18
+ const minProbability = options.minProbability ?? 0;
19
+ const minConfidence = options.minConfidence ?? 0;
20
+ if (!inUnit(minProbability) || !inUnit(minConfidence)) {
21
+ throw new ConfigurationError('INVALID_CONFIG', 'decisionExecutor({ minProbability, minConfidence }) must be between 0 and 1');
22
+ }
23
+ const textModel = options.textModel;
24
+ return {
25
+ name: 'decision',
26
+ version: '1',
27
+ cache: 'inherit',
28
+ ...(textModel === undefined ? {} : { model: textModel }),
29
+ async runStep(ctx) {
30
+ return run(ctx, options, minProbability, minConfidence);
31
+ },
32
+ };
33
+ }
34
+ /** How a valid model reads, for every model error. */
35
+ const EXAMPLE = "pass an AI SDK decision model, e.g. decisionExecutor({ model: typeSafeAi.decisionModel('jev-latest') })";
36
+ /**
37
+ * Rejects anything the AI SDK cannot decide with, at config load: a model id
38
+ * string, a language model, or a decision model that cannot answer `choice`.
39
+ */
40
+ function checkModel(model) {
41
+ if (typeof model === 'function') {
42
+ throw new ConfigurationError('INVALID_CONFIG', `decisionExecutor({ model }) got a function, not a model; call it with a model id, ${EXAMPLE}`);
43
+ }
44
+ if (typeof model !== 'object' || model === null) {
45
+ const got = typeof model === 'string' ? `the string ${JSON.stringify(model)}` : String(model);
46
+ throw new ConfigurationError('INVALID_CONFIG', `decisionExecutor({ model }) got ${got}; ${EXAMPLE}`);
47
+ }
48
+ const fields = model;
49
+ const name = `${String(fields['provider'])}/${String(fields['modelId'])}`;
50
+ if (typeof fields['doDecide'] !== 'function' && typeof fields['doEvaluate'] !== 'function') {
51
+ const kind = typeof fields['doGenerate'] === 'function' ? `the language model ${name}` : 'an object that is not a decision model';
52
+ throw new ConfigurationError('INVALID_CONFIG', `decisionExecutor({ model }) got ${kind}; ${EXAMPLE}`);
53
+ }
54
+ if (fields['specificationVersion'] !== 'v4') {
55
+ throw new ConfigurationError('INVALID_CONFIG', `decisionExecutor({ model }) got ${name}, a ${String(fields['specificationVersion'])} model; the AI SDK decides with v4 decision models`);
56
+ }
57
+ const supported = fields['supportedQuestionTypes'];
58
+ if (!Array.isArray(supported) || !supported.includes('choice')) {
59
+ throw new ConfigurationError('INVALID_CONFIG', `decisionExecutor({ model }) got ${name}, which does not answer choice questions`);
60
+ }
61
+ }
62
+ function inUnit(value) {
63
+ return Number.isFinite(value) && value >= 0 && value <= 1;
64
+ }
65
+ async function run(ctx, options, minProbability, minConfidence) {
66
+ const model = options.model;
67
+ let calls = 0;
68
+ const turns = [];
69
+ const transcript = [];
70
+ const history = seedHistory(ctx);
71
+ const language = ctx.model;
72
+ const canType = language !== undefined && ctx.target.verbs.has('type');
73
+ let previousFingerprint;
74
+ let lastTurn;
75
+ let rejectedTerminals = 0;
76
+ const finish = (verdict) => {
77
+ ctx.attachTurns(turns);
78
+ ctx.attachTranscript(transcript.join('\n'));
79
+ return verdict;
80
+ };
81
+ const gated = (decision) => decision.probability >= minProbability && decision.confidence >= minConfidence;
82
+ const describe = (decision) => `p=${decision.probability.toFixed(3)}, confidence ${decision.confidence.toFixed(3)}`;
83
+ const budgetMessage = () => `The step did not conclude within its ${ctx.budgets.maxModelCalls} decision calls.`;
84
+ /**
85
+ * The action space of a complete observation; undefined when the tree is
86
+ * missing or empty. Verdict views pass `typing: true` so a field the step
87
+ * filled keeps its row and value even when the step itself cannot type.
88
+ */
89
+ const spaceOf = (observation, typing) => {
90
+ const tree = observation.tree;
91
+ if (observation.treeUnavailable || tree === undefined || emptyTree(observation))
92
+ return undefined;
93
+ return actionSpace(ctx, { path: observation.path ?? '', viewport: observation.viewport, tree }, typing);
94
+ };
95
+ const ask = async (request) => {
96
+ if (calls >= ctx.budgets.maxModelCalls)
97
+ return undefined;
98
+ transcript.push(JSON.stringify({ state: request.state, questions: request.questions }));
99
+ calls += 1;
100
+ const answers = await decide(ctx, model, request);
101
+ transcript.push(JSON.stringify(answers));
102
+ return answers;
103
+ };
104
+ const askText = async (field, page) => {
105
+ if (calls >= ctx.budgets.maxModelCalls)
106
+ return undefined;
107
+ if (language === undefined)
108
+ throw invalid('The decision model chose type with no text model configured.');
109
+ calls += 1;
110
+ return fieldText(ctx, language, {
111
+ goal: ctx.step.instruction,
112
+ context: ctx.agentContext ?? null,
113
+ params: nonSecretParams(ctx.step.params),
114
+ field,
115
+ page,
116
+ recentActions: history.slice(-6),
117
+ });
118
+ };
119
+ if (ctx.step.kind === 'assert') {
120
+ const observation = await ctx.observe({ tree: true });
121
+ const space = spaceOf(observation, true);
122
+ if (space === undefined)
123
+ return finish(inconclusive('A complete semantic observation is required.'));
124
+ const request = assertionRequest(ctx.step.instruction, observation.path ?? '', space.pageText, elementRecords(space));
125
+ const answers = await ask(request);
126
+ if (answers === undefined)
127
+ return finish(blocked(budgetMessage()));
128
+ const verdict = need(answers.verdict, 'verdict');
129
+ const evidence = describe(verdict);
130
+ if (gated(verdict) && verdict.choice === 'holds') {
131
+ return finish({ status: 'passed', summary: `The screen shows the assertion holds (${evidence}).` });
132
+ }
133
+ if (gated(verdict) && verdict.choice === 'fails') {
134
+ const summary = `The screen contradicts the assertion (${evidence}).`;
135
+ return finish({ status: 'failed', errorCode: 'ASSERTION_FAILED', summary });
136
+ }
137
+ return finish(inconclusive(`The screen does not settle the assertion (${evidence}).`));
138
+ }
139
+ for (;;) {
140
+ const observation = await ctx.observe({ tree: true });
141
+ const space = spaceOf(observation, canType);
142
+ if (space === undefined)
143
+ return finish(blocked('A complete semantic observation is required.'));
144
+ const changed = space.fingerprint !== previousFingerprint;
145
+ if (lastTurn !== undefined) {
146
+ lastTurn.outcome += changed ? ', page changed' : ', page unchanged';
147
+ lastTurn = undefined;
148
+ }
149
+ const previous = history.at(-1);
150
+ if (previous !== undefined)
151
+ history[history.length - 1] = { ...previous, pageChanged: changed };
152
+ previousFingerprint = space.fingerprint;
153
+ if (stalled(history))
154
+ return finish(blocked('Three actions in a row changed nothing on screen.'));
155
+ const answers = await ask(decisionRequest(ctx, space, history, observation.path ?? ''));
156
+ if (answers === undefined)
157
+ return finish(blocked(budgetMessage()));
158
+ const op = need(answers.operation, 'operation');
159
+ if (!gated(op))
160
+ return finish(blocked(`The next operation is uncertain (${describe(op)}).`));
161
+ if (op.choice === 'blocked')
162
+ return finish(blocked('The decision model cannot make progress.'));
163
+ if (op.choice === 'done' || op.choice === 'failed') {
164
+ const terminal = await terminalCheck(op.choice);
165
+ if (terminal !== undefined)
166
+ return finish(terminal);
167
+ continue;
168
+ }
169
+ const { taken, targetAnswer } = resolveTarget(op.choice, answers, space);
170
+ if (targetAnswer !== undefined && !gated(targetAnswer)) {
171
+ return finish(blocked(`The next target is uncertain (${describe(targetAnswer)}).`));
172
+ }
173
+ if (op.choice === 'type') {
174
+ const text = await askText(elementField(space, taken.elementKey), space.pageText);
175
+ if (text === undefined)
176
+ return finish(blocked(budgetMessage()));
177
+ if (text === null || text === '') {
178
+ history.push({ action: taken.target.description, error: 'no value for this field' });
179
+ continue;
180
+ }
181
+ await runTarget(taken, op, targetAnswer, space, text, text);
182
+ continue;
183
+ }
184
+ let secret;
185
+ if (op.choice === 'typeSecret') {
186
+ const resolved = resolveSecret(answers.secret);
187
+ if (resolved.answer !== undefined && !gated(resolved.answer)) {
188
+ return finish(blocked(`The next secret is uncertain (${describe(resolved.answer)}).`));
189
+ }
190
+ secret = resolved.name;
191
+ }
192
+ await runTarget(taken, op, targetAnswer, space, secret);
193
+ }
194
+ /** Resolves the chosen operation to a bound target. Throws MODEL_OUTPUT_INVALID for anything not offered. */
195
+ function resolveTarget(choice, answers, space) {
196
+ const control = space.controls.get(choice);
197
+ if (control !== undefined)
198
+ return { taken: { target: control, operation: choice, elementKey: '' } };
199
+ const group = space.targets.get(choice);
200
+ if (group === undefined)
201
+ throw invalid('The decision model chose an unavailable operation.');
202
+ if (group.size === 1) {
203
+ const only = group.entries().next();
204
+ if (only.done === true)
205
+ throw invalid('The decision model chose an unavailable target.');
206
+ return { taken: { target: only.value[1], operation: choice, elementKey: only.value[0] } };
207
+ }
208
+ const answer = answers[`${choice}_target`];
209
+ if (answer === undefined)
210
+ throw invalid('The decision model returned no target answer.');
211
+ const target = group.get(answer.choice);
212
+ if (target === undefined)
213
+ throw invalid('The decision model chose an unavailable target.');
214
+ return { taken: { target, operation: choice, elementKey: answer.choice }, targetAnswer: answer };
215
+ }
216
+ /** Resolves the secret fill: the declared secret, or the gated secret answer with 2+ secrets. */
217
+ function resolveSecret(answer) {
218
+ const secrets = ctx.step.secrets;
219
+ if (secrets.length === 1 && secrets[0] !== undefined)
220
+ return { name: secrets[0].name };
221
+ if (answer === undefined)
222
+ throw invalid('The decision model returned no secret answer.');
223
+ const found = secrets.find((secret) => secret.name === answer.choice);
224
+ if (found === undefined)
225
+ throw invalid('The decision model chose an undeclared secret.');
226
+ return { name: found.name, answer };
227
+ }
228
+ /**
229
+ * Dispatches a bound target and records the turn and the history entry.
230
+ * Action failures go back to the model as history; only the runtime's own
231
+ * stop codes propagate.
232
+ */
233
+ async function runTarget(taken, op, targetAnswer, space, argument, typedText) {
234
+ const targetPart = targetAnswer === undefined ? '' : `, target p=${targetAnswer.probability.toFixed(3)}`;
235
+ const turn = {
236
+ index: turns.length + 1,
237
+ calls: [callLabel(space, taken, op.choice, argument)],
238
+ outcome: `op p=${op.probability.toFixed(3)}${targetPart}`,
239
+ };
240
+ turns.push(turn);
241
+ lastTurn = turn;
242
+ const typed = typedText === undefined ? {} : { text: typedText };
243
+ try {
244
+ await taken.target.run(argument);
245
+ }
246
+ catch (error) {
247
+ if (isAgentError(error) && RUNTIME_CODES.has(error.code))
248
+ throw error;
249
+ const code = isAgentError(error) ? error.code : error instanceof Error ? error.name : 'unknown';
250
+ history.push({ action: taken.target.description, ...typed, error: code });
251
+ return;
252
+ }
253
+ history.push({ action: taken.target.description, ...typed });
254
+ }
255
+ /** One turn call label, e.g. type [3] textbox "New todo" = "Buy milk". */
256
+ function callLabel(space, taken, operation, text) {
257
+ if (taken.elementKey === '')
258
+ return taken.target.description;
259
+ const element = space.elements.find((item) => item.index === targetKeyIndex(taken.elementKey));
260
+ if (element === undefined)
261
+ return taken.target.description;
262
+ const value = text === undefined ? '' : ` = ${JSON.stringify(text)}`;
263
+ return `${operation} [${element.index}] ${element.role} ${JSON.stringify(element.label)}${value}`;
264
+ }
265
+ /** Field the text helper writes, falling back to the key when the element is gone. */
266
+ function elementField(space, key) {
267
+ const element = space.elements.find((item) => item.index === targetKeyIndex(key));
268
+ if (element === undefined)
269
+ return { label: key, role: 'textbox' };
270
+ return { label: element.label, role: element.role, ...(element.value === undefined ? {} : { value: element.value }) };
271
+ }
272
+ /**
273
+ * Verifies a done/failed claim on a fresh screen with the actions taken so
274
+ * far, but no model reasoning. Returns a verdict, or undefined to continue
275
+ * the loop. A verdict under the gates confirms nothing.
276
+ */
277
+ async function terminalCheck(claim) {
278
+ const observation = await ctx.observe({ tree: true });
279
+ const space = spaceOf(observation, true);
280
+ if (space === undefined)
281
+ return rejectClaim(claim, 'incomplete observation');
282
+ const request = completionRequest({
283
+ goal: ctx.step.instruction,
284
+ params: nonSecretParams(ctx.step.params),
285
+ path: observation.path ?? '',
286
+ pageText: space.pageText,
287
+ elements: elementRecords(space),
288
+ history,
289
+ });
290
+ const answers = await ask(request);
291
+ if (answers === undefined)
292
+ return blocked(budgetMessage());
293
+ const verdict = need(answers.verdict, 'verdict');
294
+ const settled = gated(verdict) ? verdict.choice : 'below gate';
295
+ if (claim === 'done' && settled === 'holds') {
296
+ return { status: 'passed', summary: `The screen shows the step is done (${describe(verdict)}).` };
297
+ }
298
+ if (claim === 'failed' && settled === 'fails') {
299
+ const summary = `The screen shows the step failed (${describe(verdict)}).`;
300
+ return { status: 'failed', errorCode: 'ACTION_FAILED', summary };
301
+ }
302
+ return rejectClaim(claim, settled);
303
+ }
304
+ /**
305
+ * Records a rejected terminal claim and continues the loop. The second
306
+ * rejected claim in a step ends it, decided by the check: visible
307
+ * counter-evidence is a product failure, anything else an automation limit.
308
+ */
309
+ function rejectClaim(claim, check) {
310
+ rejectedTerminals += 1;
311
+ history.push({ action: claim, error: `check: ${check}` });
312
+ if (rejectedTerminals < 2)
313
+ return undefined;
314
+ if (check === 'fails') {
315
+ return { status: 'failed', errorCode: 'ACTION_FAILED', summary: `The screen contradicts the ${claim} claim.` };
316
+ }
317
+ return blocked(`The screen does not confirm the ${claim} claim (check: ${check}).`);
318
+ }
319
+ }
320
+ /** A model answer the request never offered: our validation, never the provider's text. */
321
+ function invalid(message) {
322
+ return new AgentError('MODEL_OUTPUT_INVALID', message);
323
+ }
324
+ /** Requires an answer the model returned; its absence is our bug or a broken transport. */
325
+ function need(answer, what) {
326
+ if (answer === undefined)
327
+ throw invalid(`The decision model returned no ${what} answer.`);
328
+ return answer;
329
+ }
330
+ /**
331
+ * Seeds history from a replayed prefix, flagging the uncertain action. The
332
+ * runner's summaries name a filled secret's handle (`fill secret "admin.password"
333
+ * into ...`, cut at 40 characters); secret names reach the model only as
334
+ * `secret` question criteria, so that handle becomes `<secret>`. Labels and
335
+ * values elsewhere in a summary stay as they are, even when they match a name.
336
+ */
337
+ function seedHistory(ctx) {
338
+ const prefix = ctx.replayedPrefix;
339
+ if (prefix === undefined)
340
+ return [];
341
+ const redact = (summary) => summary.replace(SECRET_FILL, 'fill secret <secret>');
342
+ const seeded = prefix.replayedActions.map((action) => ({ action: redact(action), replayed: true }));
343
+ if (prefix.uncertainAction !== undefined) {
344
+ seeded.push({ action: redact(prefix.uncertainAction), replayed: true, uncertain: true });
345
+ }
346
+ return seeded;
347
+ }
348
+ /** Three recorded actions in a row with no page change. */
349
+ function stalled(history) {
350
+ if (history.length < 3)
351
+ return false;
352
+ return history.slice(-3).every((entry) => entry.pageChanged === false);
353
+ }
354
+ /** No nodes to build an action space from. A truncated observation still acts on what is there. */
355
+ function emptyTree(observation) {
356
+ const tree = observation.tree;
357
+ return tree === undefined || ((tree.children ?? []).length === 0 && observation.text.trim() === '');
358
+ }
359
+ /** An automation limitation says nothing about the application correctness. */
360
+ function blocked(summary) {
361
+ return { status: 'blocked', errorCode: 'AUTOMATION_UNSUPPORTED', summary };
362
+ }
363
+ /** Insufficient evidence must never become an assertion pass. */
364
+ function inconclusive(summary) {
365
+ return { status: 'failed', errorCode: 'ASSERTION_INCONCLUSIVE', summary };
366
+ }
@@ -0,0 +1,2 @@
1
+ export { decisionExecutor } from './executor.ts';
2
+ export type { DecisionExecutorOptions } from './types.ts';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { decisionExecutor } from './executor.js';
@@ -0,0 +1,56 @@
1
+ import type { JsonValue, StepExecutorContext } from 'e2e';
2
+ import type { ActionSpace } from './elements.ts';
3
+ /** One step of history the decision model reads. */
4
+ export interface HistoryEntry {
5
+ readonly action: string;
6
+ readonly text?: string;
7
+ readonly pageChanged?: boolean;
8
+ readonly error?: string;
9
+ readonly replayed?: true;
10
+ readonly uncertain?: true;
11
+ }
12
+ /** One choice question as the request sends it. */
13
+ interface ChoiceQuestion {
14
+ readonly type: 'choice';
15
+ readonly instructions: Record<string, JsonValue>;
16
+ readonly criteria: Record<string, JsonValue | null>;
17
+ }
18
+ export interface DecisionRequest {
19
+ readonly state: Record<string, JsonValue>;
20
+ readonly questions: Record<string, ChoiceQuestion>;
21
+ }
22
+ /**
23
+ * Builds one decide request: the operation question plus one target
24
+ * question per operation with two or more targets, and a secret question
25
+ * when two or more secrets are declared. Operations with no targets are
26
+ * left out; a lone target dispatches with no question.
27
+ */
28
+ export declare function decisionRequest(ctx: StepExecutorContext, space: ActionSpace, history: readonly HistoryEntry[], path: string): DecisionRequest;
29
+ /** The step params minus secret projections, which travel only as handles. */
30
+ export declare function nonSecretParams(params: Readonly<Record<string, JsonValue>> | undefined): Record<string, JsonValue>;
31
+ /** Element index behind a target key (`7` for both `7` and `7:2`). */
32
+ export declare function targetKeyIndex(key: string): string;
33
+ /** Element table rows as plain records, shared by decision and verdict states. */
34
+ export declare function elementRecords(space: ActionSpace): Record<string, JsonValue>[];
35
+ /**
36
+ * Builds an assertion verdict request: the goal, the page, and the element
37
+ * table. No history: an assertion judges the screen alone.
38
+ */
39
+ export declare function assertionRequest(goal: string, path: string, pageText: string, elements: readonly Record<string, JsonValue>[]): DecisionRequest;
40
+ /** What a completion check reads: the task, its inputs, the screen, and what the step did. */
41
+ export interface CompletionInput {
42
+ readonly goal: string;
43
+ readonly params: Record<string, JsonValue>;
44
+ readonly path: string;
45
+ readonly pageText: string;
46
+ readonly elements: readonly Record<string, JsonValue>[];
47
+ readonly history: readonly HistoryEntry[];
48
+ }
49
+ /**
50
+ * Builds a completion-check request: the goal with its non-secret params, the
51
+ * page, the element table, and the actions the step took with the values it
52
+ * typed. Rejected done/failed claims are left out (they are not actions), and
53
+ * so is any model reasoning.
54
+ */
55
+ export declare function completionRequest(input: CompletionInput): DecisionRequest;
56
+ export {};