@e2e-dev/decision 0.2.0-nightly-20261009094406 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/executor.js CHANGED
@@ -1,9 +1,13 @@
1
- import { AgentError, isAgentError } from 'e2e/agent';
1
+ import { isAgentError } from 'e2e/agent';
2
2
  import { ConfigurationError } from 'e2e/engine';
3
3
  import { decide, requireDecide } from './decide.js';
4
+ import { perform } from './dispatch.js';
4
5
  import { actionSpace } from './elements.js';
5
- import { assertionRequest, completionRequest, decisionRequest, elementRecords, nonSecretParams, targetKeyIndex, } from './questions.js';
6
- import { fieldText } from './text.js';
6
+ import { PointLocator } from './locator.js';
7
+ import { nonSecretParams } from './params.js';
8
+ import { describe, gated, invalid, need, pick, verdictOf } from './picks.js';
9
+ import { assertionRequest, completionRequest, operationRequest, targetRequest } from './questions.js';
10
+ import { askText, PATHS, TARGET, TEXT } from './text.js';
7
11
  /** Error codes the runtime owns: rethrown untouched, never absorbed as history. */
8
12
  const RUNTIME_CODES = new Set(['STEP_BUDGET_EXHAUSTED', 'STEP_TIMEOUT', 'CANCELLED']);
9
13
  /** The handle in the runner's secret-fill summary: `fill secret "<json string>"` at the start. */
@@ -15,22 +19,25 @@ export function decisionExecutor(options) {
15
19
  }
16
20
  requireDecide();
17
21
  checkModel(options.model);
18
- const minProbability = options.minProbability ?? 0;
19
- const minConfidence = options.minConfidence ?? 0;
20
- if (!inUnit(minProbability) || !inUnit(minConfidence)) {
22
+ const gates = { minProbability: options.minProbability ?? 0, minConfidence: options.minConfidence ?? 0 };
23
+ if (!inUnit(gates.minProbability) || !inUnit(gates.minConfidence)) {
21
24
  throw new ConfigurationError('INVALID_CONFIG', 'decisionExecutor({ minProbability, minConfidence }) must be between 0 and 1');
22
25
  }
23
26
  if (options.providerOptions !== undefined && !isOptionsRecord(options.providerOptions)) {
24
27
  throw new ConfigurationError('INVALID_CONFIG', 'decisionExecutor({ providerOptions }) maps provider names to option objects, e.g. { gateway: { zeroDataRetention: true } }');
25
28
  }
29
+ if (options.vision !== undefined && typeof options.vision !== 'boolean') {
30
+ throw new ConfigurationError('INVALID_CONFIG', 'decisionExecutor({ vision }) must be a boolean');
31
+ }
26
32
  const textModel = options.textModel;
27
33
  return {
28
34
  name: 'decision',
29
- version: '1',
35
+ version: '2',
30
36
  cache: 'inherit',
31
37
  ...(textModel === undefined ? {} : { model: textModel }),
38
+ ...(options.vision === true ? { vision: true } : {}),
32
39
  async runStep(ctx) {
33
- return run(ctx, options, minProbability, minConfidence);
40
+ return new Step(ctx, options, gates).run();
34
41
  },
35
42
  };
36
43
  }
@@ -75,254 +82,318 @@ function isOptionsRecord(value) {
75
82
  function inUnit(value) {
76
83
  return Number.isFinite(value) && value >= 0 && value <= 1;
77
84
  }
78
- async function run(ctx, options, minProbability, minConfidence) {
79
- const model = options.model;
80
- let calls = 0;
81
- const turns = [];
82
- const transcript = [];
83
- const history = seedHistory(ctx);
84
- const language = ctx.model;
85
- const canType = language !== undefined && ctx.target.verbs.has('type');
86
- let previousFingerprint;
87
- let lastTurn;
88
- let rejectedTerminals = 0;
89
- const finish = (verdict) => {
90
- ctx.attachTurns(turns);
91
- ctx.attachTranscript(transcript.join('\n'));
92
- return verdict;
93
- };
94
- const gated = (decision) => decision.probability >= minProbability && decision.confidence >= minConfidence;
95
- const describe = (decision) => `p=${decision.probability.toFixed(3)}, confidence ${decision.confidence.toFixed(3)}`;
96
- const budgetMessage = () => `The step did not conclude within its ${ctx.budgets.maxModelCalls} decision calls.`;
85
+ /**
86
+ * One `agent.act` or `agent.assert` step: the loop, the budget, the history
87
+ * the model reads, and the turns and transcript the runner keeps.
88
+ */
89
+ class Step {
90
+ ctx;
91
+ options;
92
+ gates;
93
+ calls = 0;
94
+ turns = [];
95
+ transcript = [];
96
+ history;
97
+ language;
98
+ canType;
99
+ vision;
100
+ /** Whether the step can locate a drawn control: the decision model scores and the text model names it. */
101
+ locates;
102
+ locator;
103
+ previousFingerprint;
104
+ /** The turn of the last action taken, settled by the next observation. */
105
+ pendingTurn;
106
+ rejectedTerminals = 0;
107
+ attached = false;
108
+ constructor(ctx, options, gates) {
109
+ this.ctx = ctx;
110
+ this.options = options;
111
+ this.gates = gates;
112
+ this.history = seedHistory(ctx);
113
+ this.language = ctx.model;
114
+ this.canType = this.language !== undefined && ctx.target.verbs.has('type');
115
+ this.vision = options.vision === true;
116
+ this.locates = this.language !== undefined && options.model.supportedQuestionTypes.includes('score');
117
+ this.locator = new PointLocator(ctx, (request) => this.ask(request), gates);
118
+ }
119
+ async run() {
120
+ try {
121
+ return await (this.ctx.step.kind === 'assert' ? this.judge() : this.act());
122
+ }
123
+ finally {
124
+ // A thrown step keeps its evidence too: the transcript is what explains a refusal or a bad answer.
125
+ this.attach();
126
+ }
127
+ }
128
+ /** Whether this step may show pixels: vision is on and no secret has been filled in the attempt. */
129
+ get pixelsAllowed() {
130
+ return this.vision && !this.ctx.pixelsTainted;
131
+ }
97
132
  /**
98
- * The action space of a complete observation; undefined when the tree is
99
- * missing or empty. Verdict views pass `typing: true` so a field the step
100
- * filled keeps its row and value even when the step itself cannot type.
133
+ * A fresh look: the tree, and masked pixels when allowed. `typing` keeps a
134
+ * filled field's row for verdict views.
101
135
  */
102
- const spaceOf = (observation, typing) => {
136
+ async view(typing) {
137
+ const observation = await this.ctx.observe({ tree: true, pixels: this.pixelsAllowed });
138
+ const path = observation.path ?? '';
139
+ const screenshot = this.pixelsAllowed ? observation.pixels : undefined;
103
140
  const tree = observation.tree;
104
141
  if (observation.treeUnavailable || tree === undefined || emptyTree(observation))
142
+ return { path, space: undefined, screenshot };
143
+ const base = { path, viewport: observation.viewport, tree };
144
+ const seen = screenshot === undefined ? base : { ...base, pixels: screenshot, locates: this.locates };
145
+ return { path, space: actionSpace(this.ctx, seen, typing), screenshot };
146
+ }
147
+ /** One decide call under the step budget, or undefined once it is spent. */
148
+ async ask(request) {
149
+ if (!this.spend())
105
150
  return undefined;
106
- return actionSpace(ctx, { path: observation.path ?? '', viewport: observation.viewport, tree }, typing);
107
- };
108
- const ask = async (request) => {
109
- if (calls >= ctx.budgets.maxModelCalls)
110
- return undefined;
111
- transcript.push(JSON.stringify({ state: request.state, questions: request.questions }));
112
- calls += 1;
113
- const answers = await decide(ctx, model, request, options.providerOptions);
114
- transcript.push(JSON.stringify(answers));
151
+ this.transcript.push(transcriptLine(request));
152
+ const answers = await decide(this.ctx, this.options.model, request, this.options.providerOptions);
153
+ this.transcript.push(JSON.stringify(answers));
115
154
  return answers;
116
- };
117
- const askText = async (field, page) => {
118
- if (calls >= ctx.budgets.maxModelCalls)
155
+ }
156
+ /** One text-model call under the step budget, or undefined once it is spent. */
157
+ async askText(spec, operation, page, field) {
158
+ if (this.language === undefined)
159
+ throw invalid(`The decision model chose ${operation} with no text model configured.`);
160
+ if (!this.spend())
119
161
  return undefined;
120
- if (language === undefined)
121
- throw invalid('The decision model chose type with no text model configured.');
122
- calls += 1;
123
- return fieldText(ctx, language, {
124
- goal: ctx.step.instruction,
125
- context: ctx.agentContext ?? null,
126
- params: nonSecretParams(ctx.step.params),
127
- field,
162
+ return askText(this.ctx, this.language, spec, {
163
+ goal: this.ctx.step.instruction,
164
+ context: this.ctx.agentContext ?? null,
165
+ params: nonSecretParams(this.ctx.step.params),
166
+ ...(field === undefined ? {} : { field }),
128
167
  page,
129
- recentActions: history.slice(-6),
168
+ recentActions: this.history.slice(-spec.history),
130
169
  });
131
- };
132
- if (ctx.step.kind === 'assert') {
133
- const observation = await ctx.observe({ tree: true });
134
- const space = spaceOf(observation, true);
135
- if (space === undefined)
136
- return finish(inconclusive('A complete semantic observation is required.'));
137
- const request = assertionRequest(ctx.step.instruction, observation.path ?? '', space.pageText, elementRecords(space));
138
- const answers = await ask(request);
139
- if (answers === undefined)
140
- return finish(blocked(budgetMessage()));
141
- const verdict = need(answers.verdict, 'verdict');
142
- const evidence = describe(verdict);
143
- if (gated(verdict) && verdict.choice === 'holds') {
144
- return finish({ status: 'passed', summary: `The screen shows the assertion holds (${evidence}).` });
145
- }
146
- if (gated(verdict) && verdict.choice === 'fails') {
147
- const summary = `The screen contradicts the assertion (${evidence}).`;
148
- return finish({ status: 'failed', errorCode: 'ASSERTION_FAILED', summary });
149
- }
150
- return finish(inconclusive(`The screen does not settle the assertion (${evidence}).`));
151
170
  }
152
- for (;;) {
153
- const observation = await ctx.observe({ tree: true });
154
- const space = spaceOf(observation, canType);
155
- if (space === undefined)
156
- return finish(blocked('A complete semantic observation is required.'));
157
- const changed = space.fingerprint !== previousFingerprint;
158
- if (lastTurn !== undefined) {
159
- lastTurn.outcome += changed ? ', page changed' : ', page unchanged';
160
- lastTurn = undefined;
161
- }
162
- const previous = history.at(-1);
163
- if (previous !== undefined)
164
- history[history.length - 1] = { ...previous, pageChanged: changed };
165
- previousFingerprint = space.fingerprint;
166
- if (stalled(history))
167
- return finish(blocked('Three actions in a row changed nothing on screen.'));
168
- const answers = await ask(decisionRequest(ctx, space, history, observation.path ?? ''));
171
+ spend() {
172
+ if (this.calls >= this.ctx.budgets.maxModelCalls)
173
+ return false;
174
+ this.calls += 1;
175
+ return true;
176
+ }
177
+ budgetMessage() {
178
+ return `The step did not conclude within its ${this.ctx.budgets.maxModelCalls} decision calls.`;
179
+ }
180
+ attach() {
181
+ if (this.attached)
182
+ return;
183
+ this.attached = true;
184
+ this.ctx.attachTurns(this.turns);
185
+ this.ctx.attachTranscript(this.transcript.join('\n'));
186
+ }
187
+ /** An assertion: one verdict on the current screen. */
188
+ async judge() {
189
+ const { path, space, screenshot } = await this.view(true);
190
+ // `vision: 'only'` judges the pixels alone, tree or no tree; without granted pixels the tree still decides.
191
+ const pixelsOnly = this.ctx.step.vision === 'only' && screenshot !== undefined;
192
+ if (space === undefined && !pixelsOnly)
193
+ return inconclusive('A complete semantic observation is required.');
194
+ const answers = await this.ask(assertionRequest(this.ctx.step.instruction, path, pixelsOnly ? undefined : space, screenshot));
169
195
  if (answers === undefined)
170
- return finish(blocked(budgetMessage()));
171
- const op = need(answers.operation, 'operation');
172
- if (!gated(op))
173
- return finish(blocked(`The next operation is uncertain (${describe(op)}).`));
174
- if (op.choice === 'blocked')
175
- return finish(blocked('The decision model cannot make progress.'));
176
- if (op.choice === 'done' || op.choice === 'failed') {
177
- const terminal = await terminalCheck(op.choice);
178
- if (terminal !== undefined)
179
- return finish(terminal);
180
- continue;
181
- }
182
- const { taken, targetAnswer } = resolveTarget(op.choice, answers, space);
183
- if (targetAnswer !== undefined && !gated(targetAnswer)) {
184
- return finish(blocked(`The next target is uncertain (${describe(targetAnswer)}).`));
185
- }
186
- if (op.choice === 'type') {
187
- const text = await askText(elementField(space, taken.elementKey), space.pageText);
188
- if (text === undefined)
189
- return finish(blocked(budgetMessage()));
190
- if (text === null || text === '') {
191
- history.push({ action: taken.target.description, error: 'no value for this field' });
196
+ return blocked(this.budgetMessage());
197
+ const evidence = describe(need(answers.verdict, 'verdict'));
198
+ const settled = verdictOf(answers.verdict, this.gates);
199
+ if (settled === 'holds')
200
+ return { status: 'passed', summary: `The screen shows the assertion holds (${evidence}).` };
201
+ if (settled === 'fails')
202
+ return { status: 'failed', errorCode: 'ASSERTION_FAILED', summary: `The screen contradicts the assertion (${evidence}).` };
203
+ return inconclusive(`The screen does not settle the assertion (${evidence}).`);
204
+ }
205
+ /** The act loop: observe, decide, perform, until a terminal claim is confirmed or the step cannot go on. */
206
+ async act() {
207
+ for (;;) {
208
+ const view = await this.view(this.canType);
209
+ if (view.space === undefined)
210
+ return blocked('A complete semantic observation is required.');
211
+ const acting = { ...view, space: view.space };
212
+ this.settle(acting);
213
+ if (stalled(this.history))
214
+ return blocked('Three actions in a row changed nothing on screen.');
215
+ const decided = await this.ask(operationRequest(this.ctx, acting.space, this.history, acting.path));
216
+ if (decided === undefined)
217
+ return blocked(this.budgetMessage());
218
+ const op = need(decided.operation, 'operation');
219
+ if (!gated(op, this.gates))
220
+ return blocked(`The next operation is uncertain (${describe(op)}).`);
221
+ if (op.choice === 'blocked')
222
+ return blocked('The decision model cannot make progress.');
223
+ if (op.choice === 'done' || op.choice === 'failed') {
224
+ const terminal = await this.terminalCheck(op.choice);
225
+ if (terminal !== undefined)
226
+ return terminal;
192
227
  continue;
193
228
  }
194
- await runTarget(taken, op, targetAnswer, space, text, text);
195
- continue;
196
- }
197
- let secret;
198
- if (op.choice === 'typeSecret') {
199
- const resolved = resolveSecret(answers.secret);
200
- if (resolved.answer !== undefined && !gated(resolved.answer)) {
201
- return finish(blocked(`The next secret is uncertain (${describe(resolved.answer)}).`));
229
+ const next = await this.prepare(op, acting);
230
+ if ('verdict' in next)
231
+ return next.verdict;
232
+ if ('skip' in next) {
233
+ this.history.push({ action: next.skip, error: next.because });
234
+ continue;
202
235
  }
203
- secret = resolved.name;
236
+ await this.take(next, op);
237
+ }
238
+ }
239
+ /**
240
+ * Settles the last action against a fresh look: whether the page changed
241
+ * goes on its turn and its history entry, and the locator learns whether
242
+ * a point tap landed.
243
+ */
244
+ settle({ space, screenshot }) {
245
+ const changed = space.fingerprint !== this.previousFingerprint;
246
+ this.previousFingerprint = space.fingerprint;
247
+ if (this.pendingTurn !== undefined) {
248
+ this.pendingTurn.outcome += changed ? ', page changed' : ', page unchanged';
249
+ this.pendingTurn = undefined;
204
250
  }
205
- await runTarget(taken, op, targetAnswer, space, secret);
251
+ const previous = this.history.at(-1);
252
+ if (previous !== undefined)
253
+ this.history[this.history.length - 1] = { ...previous, pageChanged: changed };
254
+ this.locator.settle(screenshot);
206
255
  }
207
- /** Resolves the chosen operation to a bound target. Throws MODEL_OUTPUT_INVALID for anything not offered. */
208
- function resolveTarget(choice, answers, space) {
209
- const control = space.controls.get(choice);
210
- if (control !== undefined)
211
- return { taken: { target: control, operation: choice, elementKey: '' } };
212
- const group = space.targets.get(choice);
256
+ /**
257
+ * Turns the chosen operation into an action: a control as is, a point
258
+ * through the locator, anything else through its target question and
259
+ * what the operation needs beside the target.
260
+ */
261
+ async prepare(op, view) {
262
+ const { space } = view;
263
+ const control = space.control(op.choice);
264
+ if (control !== undefined) {
265
+ return { action: { operation: control }, label: CONTROL_LABELS[control], description: CONTROL_LABELS[control] };
266
+ }
267
+ const operation = space.operation(op.choice);
268
+ if (operation === undefined)
269
+ throw invalid('The decision model chose an unavailable operation.');
270
+ if (operation === 'tap_at')
271
+ return this.prepareTapAt(view);
272
+ const group = space.targets.get(operation);
213
273
  if (group === undefined)
214
274
  throw invalid('The decision model chose an unavailable operation.');
215
- if (group.size === 1) {
216
- const only = group.entries().next();
217
- if (only.done === true)
218
- throw invalid('The decision model chose an unavailable target.');
219
- return { taken: { target: only.value[1], operation: choice, elementKey: only.value[0] } };
275
+ // The target depends on the operation: its own request, after the operation settled.
276
+ const request = targetRequest(this.ctx, space, operation, this.history, view.path);
277
+ const answers = Object.keys(request.questions).length === 0 ? {} : await this.ask(request);
278
+ if (answers === undefined)
279
+ return { verdict: blocked(this.budgetMessage()) };
280
+ const chosen = pick(answers.target, group, this.gates, 'target', operation);
281
+ if ('blocked' in chosen)
282
+ return { verdict: blocked(chosen.blocked) };
283
+ const { key, value: target } = chosen;
284
+ const element = space.element(key);
285
+ const field = fieldOf(key, element);
286
+ const prepared = (action, typed) => ({
287
+ action,
288
+ label: callLabel(element, operation, target, typed),
289
+ description: target.description,
290
+ ...(chosen.answer === undefined ? {} : { targetAnswer: chosen.answer }),
291
+ ...(typed === undefined ? {} : { typed }),
292
+ });
293
+ switch (operation) {
294
+ case 'type': {
295
+ const answer = await this.askText(TEXT, operation, space.pageText, field);
296
+ if (answer === undefined)
297
+ return { verdict: blocked(this.budgetMessage()) };
298
+ if (answer.text === null || answer.text === '')
299
+ return { skip: target.description, because: 'no value for this field' };
300
+ return prepared({ operation, target, text: answer.text }, answer.text);
301
+ }
302
+ case 'upload': {
303
+ const answer = await this.askText(PATHS, operation, space.pageText, field);
304
+ if (answer === undefined)
305
+ return { verdict: blocked(this.budgetMessage()) };
306
+ if (answer.paths.length === 0)
307
+ return { skip: target.description, because: 'no files for this input' };
308
+ return prepared({ operation, target, paths: answer.paths }, answer.paths.join(', '));
309
+ }
310
+ case 'drag': {
311
+ const destination = pick(answers.destination, space.destinations, this.gates, 'destination', operation);
312
+ if ('blocked' in destination)
313
+ return { verdict: blocked(destination.blocked) };
314
+ if (destination.value.id === target.id)
315
+ return { verdict: blocked('The decision model chose to drag an element onto itself.') };
316
+ return prepared({ operation, target, destinationId: destination.value.id }, `onto ${destination.value.label}`);
317
+ }
318
+ case 'typeSecret': {
319
+ const secret = pick(answers.secret, new Map(this.ctx.step.secrets.map((entry) => [entry.name, entry])), this.gates, 'secret', operation);
320
+ if ('blocked' in secret)
321
+ return { verdict: blocked(secret.blocked) };
322
+ return prepared({ operation, target, name: secret.value.name });
323
+ }
324
+ default:
325
+ return prepared({ operation, target });
220
326
  }
221
- const answer = answers[`${choice}_target`];
222
- if (answer === undefined)
223
- throw invalid('The decision model returned no target answer.');
224
- const target = group.get(answer.choice);
225
- if (target === undefined)
226
- throw invalid('The decision model chose an unavailable target.');
227
- return { taken: { target, operation: choice, elementKey: answer.choice }, targetAnswer: answer };
228
327
  }
229
- /** Resolves the secret fill: the declared secret, or the gated secret answer with 2+ secrets. */
230
- function resolveSecret(answer) {
231
- const secrets = ctx.step.secrets;
232
- if (secrets.length === 1 && secrets[0] !== undefined)
233
- return { name: secrets[0].name };
234
- if (answer === undefined)
235
- throw invalid('The decision model returned no secret answer.');
236
- const found = secrets.find((secret) => secret.name === answer.choice);
237
- if (found === undefined)
238
- throw invalid('The decision model chose an undeclared secret.');
239
- return { name: found.name, answer };
328
+ /** A point tap: the text model names the drawn control, the locator finds it or recalls where it last landed. */
329
+ async prepareTapAt({ space, screenshot, path }) {
330
+ if (screenshot === undefined)
331
+ throw invalid('The decision model chose tap_at with no screenshot.');
332
+ const named = await this.askText(TARGET, 'tap_at', space.pageText);
333
+ if (named === undefined)
334
+ return { verdict: blocked(this.budgetMessage()) };
335
+ const wanted = named.target;
336
+ const point = this.locator.known(path, wanted) ?? (await this.locator.locate(screenshot, path, wanted));
337
+ if (point === undefined)
338
+ return { verdict: blocked(this.budgetMessage()) };
339
+ if ('uncertain' in point)
340
+ return { verdict: blocked(`The tap point is uncertain (${point.uncertain}).`) };
341
+ this.locator.tapped(path, wanted, point, screenshot);
342
+ const label = `tap ${wanted} at (${point.x}, ${point.y})`;
343
+ return { action: { operation: 'tap_at', point }, label, description: label };
240
344
  }
241
345
  /**
242
- * Dispatches a bound target and records the turn and the history entry.
346
+ * Performs a prepared action and records the turn and the history entry.
243
347
  * Action failures go back to the model as history; only the runtime's own
244
348
  * stop codes propagate.
245
349
  */
246
- async function runTarget(taken, op, targetAnswer, space, argument, typedText) {
350
+ async take({ action, label, description, targetAnswer, typed }, op) {
247
351
  const targetPart = targetAnswer === undefined ? '' : `, target p=${targetAnswer.probability.toFixed(3)}`;
248
- const turn = {
249
- index: turns.length + 1,
250
- calls: [callLabel(space, taken, op.choice, argument)],
251
- outcome: `op p=${op.probability.toFixed(3)}${targetPart}`,
252
- };
253
- turns.push(turn);
254
- lastTurn = turn;
255
- const typed = typedText === undefined ? {} : { text: typedText };
352
+ const turn = { index: this.turns.length + 1, calls: [label], outcome: `op p=${op.probability.toFixed(3)}${targetPart}` };
353
+ this.turns.push(turn);
354
+ this.pendingTurn = turn;
355
+ const entry = { action: description, ...(typed === undefined ? {} : { text: typed }) };
256
356
  try {
257
- await taken.target.run(argument);
357
+ const note = await perform(this.ctx, action);
358
+ this.history.push(note === undefined ? entry : { ...entry, note });
258
359
  }
259
360
  catch (error) {
260
361
  if (isAgentError(error) && RUNTIME_CODES.has(error.code))
261
362
  throw error;
262
363
  const code = isAgentError(error) ? error.code : error instanceof Error ? error.name : 'unknown';
263
- history.push({ action: taken.target.description, ...typed, error: code });
264
- return;
364
+ this.history.push({ ...entry, error: code });
265
365
  }
266
- history.push({ action: taken.target.description, ...typed });
267
- }
268
- /** One turn call label, e.g. type [3] textbox "New todo" = "Buy milk". */
269
- function callLabel(space, taken, operation, text) {
270
- if (taken.elementKey === '')
271
- return taken.target.description;
272
- const element = space.elements.find((item) => item.index === targetKeyIndex(taken.elementKey));
273
- if (element === undefined)
274
- return taken.target.description;
275
- const value = text === undefined ? '' : ` = ${JSON.stringify(text)}`;
276
- return `${operation} [${element.index}] ${element.role} ${JSON.stringify(element.label)}${value}`;
277
- }
278
- /** Field the text helper writes, falling back to the key when the element is gone. */
279
- function elementField(space, key) {
280
- const element = space.elements.find((item) => item.index === targetKeyIndex(key));
281
- if (element === undefined)
282
- return { label: key, role: 'textbox' };
283
- return { label: element.label, role: element.role, ...(element.value === undefined ? {} : { value: element.value }) };
284
366
  }
285
367
  /**
286
368
  * Verifies a done/failed claim on a fresh screen with the actions taken so
287
369
  * far, but no model reasoning. Returns a verdict, or undefined to continue
288
370
  * the loop. A verdict under the gates confirms nothing.
289
371
  */
290
- async function terminalCheck(claim) {
291
- const observation = await ctx.observe({ tree: true });
292
- const space = spaceOf(observation, true);
372
+ async terminalCheck(claim) {
373
+ const { path, space, screenshot } = await this.view(true);
293
374
  if (space === undefined)
294
- return rejectClaim(claim, 'incomplete observation');
295
- const request = completionRequest({
296
- goal: ctx.step.instruction,
297
- params: nonSecretParams(ctx.step.params),
298
- path: observation.path ?? '',
299
- pageText: space.pageText,
300
- elements: elementRecords(space),
301
- history,
302
- });
303
- const answers = await ask(request);
375
+ return this.rejectClaim(claim, 'incomplete observation');
376
+ const answers = await this.ask(completionRequest(this.ctx, space, this.history, path, screenshot));
304
377
  if (answers === undefined)
305
- return blocked(budgetMessage());
306
- const verdict = need(answers.verdict, 'verdict');
307
- const settled = gated(verdict) ? verdict.choice : 'below gate';
308
- if (claim === 'done' && settled === 'holds') {
309
- return { status: 'passed', summary: `The screen shows the step is done (${describe(verdict)}).` };
310
- }
378
+ return blocked(this.budgetMessage());
379
+ const evidence = describe(need(answers.verdict, 'verdict'));
380
+ const settled = verdictOf(answers.verdict, this.gates);
381
+ if (claim === 'done' && settled === 'holds')
382
+ return { status: 'passed', summary: `The screen shows the step is done (${evidence}).` };
311
383
  if (claim === 'failed' && settled === 'fails') {
312
- const summary = `The screen shows the step failed (${describe(verdict)}).`;
313
- return { status: 'failed', errorCode: 'ACTION_FAILED', summary };
384
+ return { status: 'failed', errorCode: 'ACTION_FAILED', summary: `The screen shows the step failed (${evidence}).` };
314
385
  }
315
- return rejectClaim(claim, settled);
386
+ return this.rejectClaim(claim, settled);
316
387
  }
317
388
  /**
318
389
  * Records a rejected terminal claim and continues the loop. The second
319
390
  * rejected claim in a step ends it, decided by the check: visible
320
391
  * counter-evidence is a product failure, anything else an automation limit.
321
392
  */
322
- function rejectClaim(claim, check) {
323
- rejectedTerminals += 1;
324
- history.push({ action: claim, error: `check: ${check}` });
325
- if (rejectedTerminals < 2)
393
+ rejectClaim(claim, check) {
394
+ this.rejectedTerminals += 1;
395
+ this.history.push({ action: claim, error: `check: ${check}` });
396
+ if (this.rejectedTerminals < 2)
326
397
  return undefined;
327
398
  if (check === 'fails') {
328
399
  return { status: 'failed', errorCode: 'ACTION_FAILED', summary: `The screen contradicts the ${claim} claim.` };
@@ -330,15 +401,29 @@ async function run(ctx, options, minProbability, minConfidence) {
330
401
  return blocked(`The screen does not confirm the ${claim} claim (check: ${check}).`);
331
402
  }
332
403
  }
333
- /** A model answer the request never offered: our validation, never the provider's text. */
334
- function invalid(message) {
335
- return new AgentError('MODEL_OUTPUT_INVALID', message);
404
+ /** How a control reads in a turn and in history. */
405
+ const CONTROL_LABELS = {
406
+ scroll_up: 'scroll viewport up',
407
+ scroll_down: 'scroll viewport down',
408
+ back: 'back one step in history',
409
+ };
410
+ /** The field a text ask describes: the element's label, role, and value; a textbox named by its key when the table has no row. */
411
+ function fieldOf(key, element) {
412
+ if (element === undefined)
413
+ return { label: key, role: 'textbox' };
414
+ return { label: element.label, role: element.role, ...(element.value === undefined ? {} : { value: element.value }) };
415
+ }
416
+ /** One turn call label, e.g. type [3] textbox "New todo" = "Buy milk". */
417
+ function callLabel(element, operation, target, text) {
418
+ if (element === undefined)
419
+ return target.description;
420
+ const value = text === undefined ? '' : ` = ${JSON.stringify(text)}`;
421
+ return `${operation} [${element.index}] ${element.role} ${JSON.stringify(element.label)}${value}`;
336
422
  }
337
- /** Requires an answer the model returned; its absence is our bug or a broken transport. */
338
- function need(answer, what) {
339
- if (answer === undefined)
340
- throw invalid(`The decision model returned no ${what} answer.`);
341
- return answer;
423
+ /** One transcript line: the request without its pixels, which are named by size. */
424
+ function transcriptLine(request) {
425
+ const { screenshot, ...rest } = request;
426
+ return JSON.stringify(screenshot === undefined ? rest : { ...rest, screenshot: `${screenshot.width}x${screenshot.height}` });
342
427
  }
343
428
  /**
344
429
  * Seeds history from a replayed prefix, flagging the uncertain action. The
package/dist/hash.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** FNV-1a over text and bytes, as a base-36 string: the one hash the package fingerprints with. */
2
+ export declare function fnv1a(parts: Iterable<string | Uint8Array>): string;
package/dist/hash.js ADDED
@@ -0,0 +1,21 @@
1
+ /** FNV-1a over text and bytes, as a base-36 string: the one hash the package fingerprints with. */
2
+ export function fnv1a(parts) {
3
+ let hash = 2166136261;
4
+ for (const part of parts) {
5
+ if (typeof part === 'string') {
6
+ for (let index = 0; index < part.length; index += 1) {
7
+ hash ^= part.charCodeAt(index);
8
+ hash = Math.imul(hash, 16777619);
9
+ }
10
+ }
11
+ else {
12
+ for (const byte of part) {
13
+ hash ^= byte;
14
+ hash = Math.imul(hash, 16777619);
15
+ }
16
+ }
17
+ hash ^= 0;
18
+ hash = Math.imul(hash, 16777619);
19
+ }
20
+ return (hash >>> 0).toString(36);
21
+ }