@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/README.md +14 -21
- package/dist/.build.json +1 -1
- package/dist/budget.d.ts +19 -0
- package/dist/budget.js +29 -0
- package/dist/decide.d.ts +8 -1
- package/dist/decide.js +70 -37
- package/dist/dispatch.d.ts +37 -0
- package/dist/dispatch.js +67 -0
- package/dist/elements.d.ts +32 -7
- package/dist/elements.js +153 -74
- package/dist/executor.js +300 -215
- package/dist/hash.d.ts +2 -0
- package/dist/hash.js +21 -0
- package/dist/locator.d.ts +38 -0
- package/dist/locator.js +91 -0
- package/dist/overlay.d.ts +59 -0
- package/dist/overlay.js +121 -0
- package/dist/params.d.ts +3 -0
- package/dist/params.js +39 -0
- package/dist/picks.d.ts +36 -0
- package/dist/picks.js +49 -0
- package/dist/questions.d.ts +56 -35
- package/dist/questions.js +203 -112
- package/dist/text.d.ts +31 -5
- package/dist/text.js +67 -53
- package/dist/types.d.ts +12 -0
- package/package.json +10 -7
package/dist/executor.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
6
|
-
import {
|
|
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
|
|
19
|
-
|
|
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: '
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
*
|
|
99
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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(-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (
|
|
191
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return {
|
|
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
|
-
/**
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
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({
|
|
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
|
|
291
|
-
const
|
|
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
|
|
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
|
|
307
|
-
const settled =
|
|
308
|
-
if (claim === 'done' && settled === 'holds')
|
|
309
|
-
return { status: 'passed', summary: `The screen shows the step is done (${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
/**
|
|
338
|
-
function
|
|
339
|
-
|
|
340
|
-
|
|
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
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
|
+
}
|