@revyl/verify 0.3.2 → 0.4.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/CHANGELOG.md +50 -0
- package/README.md +59 -24
- package/dist/agent/image.d.ts +7 -0
- package/dist/agent/image.js +29 -0
- package/dist/agent/loop.d.ts +97 -0
- package/dist/agent/loop.js +215 -0
- package/dist/agent/model.d.ts +91 -0
- package/dist/agent/model.js +255 -0
- package/dist/agent/providers.d.ts +23 -0
- package/dist/agent/providers.js +58 -0
- package/dist/api/client.d.ts +1 -0
- package/dist/api/client.js +2 -2
- package/dist/auth.js +2 -2
- package/dist/cli/init.d.ts +1 -0
- package/dist/cli/init.js +34 -9
- package/dist/cli/login.js +7 -7
- package/dist/cli/main.js +84 -29
- package/dist/computer-session.d.ts +9 -0
- package/dist/computer-session.js +27 -0
- package/dist/errors.d.ts +2 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/open.d.ts +6 -0
- package/dist/open.js +6 -1
- package/dist/session.d.ts +71 -2
- package/dist/session.js +230 -12
- package/docs/README.md +4 -3
- package/docs/agents.md +5 -5
- package/docs/api.md +89 -13
- package/docs/computers.md +9 -11
- package/docs/configuration.md +11 -7
- package/docs/errors.md +7 -5
- package/docs/getting-started.md +22 -17
- package/docs/llms.txt +6 -5
- package/docs/mobile.md +46 -0
- package/docs/own-model.md +216 -0
- package/docs/web.md +56 -1
- package/package.json +2 -3
package/dist/session.js
CHANGED
|
@@ -6,16 +6,22 @@ import { apiError, edgeBlockedError, parseJson, responseDetail, responseErrorCod
|
|
|
6
6
|
import { trackSession, untrackSession } from './cleanup.js';
|
|
7
7
|
import { VerifyError, errorMessage, isVerifyError } from './errors.js';
|
|
8
8
|
import { screenHasContent } from './screen-readiness.js';
|
|
9
|
+
import { runLocalStep } from './agent/loop.js';
|
|
10
|
+
import { chooseAgent } from './agent/providers.js';
|
|
9
11
|
const POLL_INTERVAL_MS = 2_000;
|
|
10
12
|
const DEVICE_CONNECT_TIMEOUT_MS = 90_000;
|
|
11
13
|
const STEP_POLL_BASE_MS = 500;
|
|
12
14
|
const STEP_POLL_MAX_MS = 5_000;
|
|
15
|
+
// Revyl can briefly answer a just-accepted step's status with 404 "Live step not found" before its run is listed.
|
|
16
|
+
const LIVE_STEP_GRACE_MS = 10_000;
|
|
17
|
+
const LIVE_STEP_NOT_FOUND = /^live step not found/i;
|
|
13
18
|
const STEP_CANCEL_TIMEOUT_MS = 5_000;
|
|
14
19
|
const MAX_STEP_TEXT_LENGTH = 4_000;
|
|
15
20
|
const ENDED_STATUSES = new Set(['completed', 'failed', 'cancelled', 'timeout', 'stopping']);
|
|
16
21
|
const STEP_TERMINAL_STATUSES = new Set(['completed', 'failed']);
|
|
17
22
|
const SESSION_NOT_RUNNING = /session is (not running|not active|stopping)/i;
|
|
18
23
|
const UNCERTAIN_ERROR_CODES = new Set(['request_timeout', 'connection_closed', 'protocol_error']);
|
|
24
|
+
const WEB_TARGET_ERRORS = new Set(['stale_target', 'target_unstable', 'invalid_coordinates', 'target_not_found', 'unsupported_web_element', 'invalid_action', 'grounding_failed', 'password_field_required', 'password_focus_changed']);
|
|
19
25
|
/** A Revyl cloud browser or iOS or Android device. */
|
|
20
26
|
export class DeviceSession {
|
|
21
27
|
client;
|
|
@@ -34,6 +40,8 @@ export class DeviceSession {
|
|
|
34
40
|
stepRunning = false;
|
|
35
41
|
parent;
|
|
36
42
|
readySignal;
|
|
43
|
+
size;
|
|
44
|
+
attribution;
|
|
37
45
|
get expiresAt() {
|
|
38
46
|
return this.parent?.expiresAt;
|
|
39
47
|
}
|
|
@@ -139,7 +147,7 @@ export class DeviceSession {
|
|
|
139
147
|
: reason === 'computer_not_found' || response.status === 404 ? 'NOT_FOUND'
|
|
140
148
|
: reason === 'computer_not_running' ? 'SESSION_ENDED'
|
|
141
149
|
: reason === 'computer_cannot_host_platform' || reason === 'computer_runtime_unavailable' || reason === 'web_runtime_unavailable' || reason === 'web_runtime_not_configured' || reason === 'runtime_unavailable' || reason === 'not_available_on_platform' ? 'NOT_AVAILABLE_ON_PLATFORM'
|
|
142
|
-
: reason === 'computer_at_capacity' || reason === 'computer_limit' ? 'COMPUTER_LIMIT'
|
|
150
|
+
: reason === 'computer_at_capacity' || reason === 'computer_platform_at_capacity' || reason === 'computer_limit' ? 'COMPUTER_LIMIT'
|
|
143
151
|
: response.status === 422 || response.status === 400 ? 'INVALID_ARGUMENT'
|
|
144
152
|
: response.status === 409 ? 'SESSION_BUSY'
|
|
145
153
|
: response.status === 408 || response.status >= 500 ? 'ACTION_UNCERTAIN' : 'API_ERROR';
|
|
@@ -178,7 +186,7 @@ export class DeviceSession {
|
|
|
178
186
|
}
|
|
179
187
|
/** Asks Revyl's agent to do something on the app, such as "add the first product to the cart". Throws INSTRUCTION_FAILED when it cannot. */
|
|
180
188
|
async instruction(text, options = {}) {
|
|
181
|
-
const result = await this.step('instruction', text, options
|
|
189
|
+
const result = await this.step('instruction', text, options);
|
|
182
190
|
if (!result.passed) {
|
|
183
191
|
throw new VerifyError('INSTRUCTION_FAILED', `Instruction failed: "${result.text}". ${sentence(result.reason)} See ${this.viewerUrl}`);
|
|
184
192
|
}
|
|
@@ -189,7 +197,7 @@ export class DeviceSession {
|
|
|
189
197
|
* unless soft is set, and STEP_FAILED when Revyl could not reach a verdict.
|
|
190
198
|
*/
|
|
191
199
|
async validation(text, options = {}) {
|
|
192
|
-
const result = await this.step('validation', text, options
|
|
200
|
+
const result = await this.step('validation', text, options);
|
|
193
201
|
if (!result.passed && !options.soft) {
|
|
194
202
|
throw new VerifyError('VALIDATION_FAILED', `Validation failed: "${result.text}". ${sentence(result.reason)} See ${this.viewerUrl}`);
|
|
195
203
|
}
|
|
@@ -215,9 +223,12 @@ export class DeviceSession {
|
|
|
215
223
|
throw new VerifyError('API_ERROR', 'Revyl returned an invalid session report.');
|
|
216
224
|
return report;
|
|
217
225
|
}
|
|
218
|
-
|
|
226
|
+
navigate(url) {
|
|
227
|
+
return this.navigateTo(url);
|
|
228
|
+
}
|
|
229
|
+
async navigateTo(url, signal) {
|
|
219
230
|
if (this.platform !== 'web')
|
|
220
|
-
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'navigate() is available only on web sessions.');
|
|
231
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'navigate() is available only on web sessions; use openUrl() on iOS and Android.');
|
|
221
232
|
if (typeof url !== 'string' || url.length > 2048 || /[\x00-\x20\x7f-\x9f\s\\]/.test(url))
|
|
222
233
|
throw new VerifyError('INVALID_ARGUMENT', 'navigate() requires an http(s) URL without credentials, whitespace, or backslashes.');
|
|
223
234
|
let parsed;
|
|
@@ -229,7 +240,8 @@ export class DeviceSession {
|
|
|
229
240
|
}
|
|
230
241
|
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password)
|
|
231
242
|
throw new VerifyError('INVALID_ARGUMENT', 'navigate() requires an http(s) URL without credentials.');
|
|
232
|
-
|
|
243
|
+
signal?.throwIfAborted();
|
|
244
|
+
const response = await this.worker('POST', 'open_url', { body: { url }, retry: false, ...(signal ? { signal } : {}) });
|
|
233
245
|
const result = parseJson(response, 'open_url');
|
|
234
246
|
if (!result || typeof result !== 'object' || typeof result.success !== 'boolean' || (result.action !== undefined && result.action !== 'navigate') || (result.status_code !== undefined && (!Number.isInteger(result.status_code) || result.status_code < 100 || result.status_code > 599)))
|
|
235
247
|
throw new VerifyError('API_ERROR', 'Revyl returned an invalid navigation response.');
|
|
@@ -279,11 +291,12 @@ export class DeviceSession {
|
|
|
279
291
|
[Symbol.asyncDispose]() {
|
|
280
292
|
return this.close();
|
|
281
293
|
}
|
|
282
|
-
async step(type, text,
|
|
294
|
+
async step(type, text, options) {
|
|
283
295
|
if (typeof text !== 'string' || text.trim() === '' || text.length > MAX_STEP_TEXT_LENGTH) {
|
|
284
296
|
throw new VerifyError('INVALID_ARGUMENT', `${type}() takes a description of 1 to ${MAX_STEP_TEXT_LENGTH} characters.`);
|
|
285
297
|
}
|
|
286
|
-
const
|
|
298
|
+
const agent = chooseAgent(options, `${type}()`) ?? this.options.agent;
|
|
299
|
+
const timeoutMs = options.timeout ?? this.options.stepTimeout;
|
|
287
300
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000) {
|
|
288
301
|
throw new VerifyError('INVALID_ARGUMENT', `${type}() takes a timeout of at least 1000 ms.`);
|
|
289
302
|
}
|
|
@@ -294,6 +307,13 @@ export class DeviceSession {
|
|
|
294
307
|
const description = text.trim();
|
|
295
308
|
const startedAt = Date.now();
|
|
296
309
|
try {
|
|
310
|
+
if (agent) {
|
|
311
|
+
const local = await this.runLocal(type, description, timeoutMs, agent);
|
|
312
|
+
const result = { type, text: description, passed: local.passed, ...(local.reason ? { reason: local.reason } : {}), durationMs: Date.now() - startedAt, model: local.run };
|
|
313
|
+
this.steps.push(result);
|
|
314
|
+
this.log(`${local.passed ? '✓' : '✗'} ${type} "${description}" (${formatDuration(result.durationMs)}, ${local.run.turns.length} turn${local.run.turns.length === 1 ? '' : 's'} on ${local.run.id}, ${local.run.inputTokens + local.run.outputTokens} tokens)${!local.passed && local.reason ? `: ${local.reason}` : ''}`);
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
297
317
|
const outcome = await this.runStep(type, description, timeoutMs);
|
|
298
318
|
const output = outcome.step_output ?? {};
|
|
299
319
|
const reason = output.status_reason ?? output.reasoning ?? undefined;
|
|
@@ -336,9 +356,17 @@ export class DeviceSession {
|
|
|
336
356
|
throw new VerifyError('API_ERROR', 'Revyl answered execute_step without a result or an accepted step id.');
|
|
337
357
|
}
|
|
338
358
|
stepId = body.step_id;
|
|
359
|
+
const acceptedAt = Date.now();
|
|
360
|
+
const liveStepNotFound = (response) => response.status === 404 && LIVE_STEP_NOT_FOUND.test(responseDetail(response) ?? '');
|
|
339
361
|
for (let wait = STEP_POLL_BASE_MS;; wait = Math.min(wait * 2, STEP_POLL_MAX_MS)) {
|
|
340
362
|
await delay(wait, undefined, { signal });
|
|
341
|
-
const
|
|
363
|
+
const response = await this.worker('GET', `step_status/${encodeURIComponent(stepId)}`, { signal, retry: true, allow: liveStepNotFound });
|
|
364
|
+
if (liveStepNotFound(response)) {
|
|
365
|
+
if (Date.now() - acceptedAt < LIVE_STEP_GRACE_MS)
|
|
366
|
+
continue;
|
|
367
|
+
throw new VerifyError('STEP_FAILED', `Revyl accepted the ${type} but could not find it for ${LIVE_STEP_GRACE_MS / 1000} s: "${description}". The session is still open and the step may still be running, so wait for it to finish before sending another. See ${this.viewerUrl}`, { httpStatus: 404 });
|
|
368
|
+
}
|
|
369
|
+
const status = parseJson(response, 'step_status');
|
|
342
370
|
if (status.status === 'running')
|
|
343
371
|
continue;
|
|
344
372
|
if (status.status === 'cancelled') {
|
|
@@ -365,6 +393,169 @@ export class DeviceSession {
|
|
|
365
393
|
throw new VerifyError('TIMEOUT', `The ${type} did not finish within ${timeoutMs} ms and was cancelled: "${description}". See ${this.viewerUrl}`);
|
|
366
394
|
}
|
|
367
395
|
}
|
|
396
|
+
async runLocal(type, description, timeoutMs, agent) {
|
|
397
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
398
|
+
this.attribution = {
|
|
399
|
+
'X-Revyl-Agent': `Verify local: ${agent.model.name}`.replace(/[^\x20-\x7e]/g, '?').slice(0, 100),
|
|
400
|
+
'X-Revyl-Agent-Session-Id': randomUUID(),
|
|
401
|
+
};
|
|
402
|
+
try {
|
|
403
|
+
return await runLocalStep(type, description, agent, this.agentControls(), signal, (line) => this.log(line));
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
if (signal.aborted && !(isVerifyError(error) && error.code !== 'TIMEOUT')) {
|
|
407
|
+
throw new VerifyError('TIMEOUT', `The ${type} did not finish within ${timeoutMs} ms on ${agent.model.name}: "${description}". See ${this.viewerUrl}`, { cause: error });
|
|
408
|
+
}
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
this.attribution = undefined;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
agentControls() {
|
|
416
|
+
const platform = this.platform;
|
|
417
|
+
if (platform !== 'ios' && platform !== 'android' && platform !== 'web')
|
|
418
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Local agents run on browsers and devices, not on ${platform}.`);
|
|
419
|
+
return {
|
|
420
|
+
platform,
|
|
421
|
+
capture: (signal) => this.capture(signal),
|
|
422
|
+
screenSize: (signal) => this.readScreenSize(signal),
|
|
423
|
+
perform: (action, screenToken, signal) => this.perform(action, screenToken, signal),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/** Taps a point in device units, or an element Revyl locates from a description. Returns the point tapped. */
|
|
427
|
+
async tap(at) {
|
|
428
|
+
return (await this.perform({ kind: 'tap', at: checkLocation(at) }));
|
|
429
|
+
}
|
|
430
|
+
/** Taps a text field and types into it, clearing it first unless clear is false. Returns the point tapped. */
|
|
431
|
+
async type(at, text, options = {}) {
|
|
432
|
+
if (typeof text !== 'string' || text.length > 16_384)
|
|
433
|
+
throw new VerifyError('INVALID_ARGUMENT', 'type() takes text of at most 16384 characters.');
|
|
434
|
+
return (await this.perform({ kind: 'type', at: checkLocation(at), text, clear: options.clear !== false }));
|
|
435
|
+
}
|
|
436
|
+
/** Swipes from a point or the screen's center. 'up' moves the finger up, which scrolls content down. */
|
|
437
|
+
async swipe(direction, options = {}) {
|
|
438
|
+
if (!['up', 'down', 'left', 'right'].includes(direction))
|
|
439
|
+
throw new VerifyError('INVALID_ARGUMENT', "swipe() takes 'up', 'down', 'left' or 'right'.");
|
|
440
|
+
await this.perform({ kind: 'swipe', direction, ...(options.from ? { from: checkLocation(options.from) } : {}) });
|
|
441
|
+
}
|
|
442
|
+
/** Presses Enter or Backspace on the focused field. */
|
|
443
|
+
async pressKey(key) {
|
|
444
|
+
if (key !== 'enter' && key !== 'backspace')
|
|
445
|
+
throw new VerifyError('INVALID_ARGUMENT', "pressKey() takes 'enter' or 'backspace'.");
|
|
446
|
+
await this.perform({ kind: 'key', key });
|
|
447
|
+
}
|
|
448
|
+
/** Goes back: browser history on Web, the back button on Android. Not on iOS. */
|
|
449
|
+
async back() {
|
|
450
|
+
await this.perform({ kind: 'back' });
|
|
451
|
+
}
|
|
452
|
+
/** Opens a URL or deep link on a device, or loads a URL in a browser. */
|
|
453
|
+
async openUrl(url) {
|
|
454
|
+
if (typeof url !== 'string' || url.length === 0 || url.length > 8_192)
|
|
455
|
+
throw new VerifyError('INVALID_ARGUMENT', 'openUrl() takes a URL of 1 to 8192 characters.');
|
|
456
|
+
if (this.platform === 'web' && !/^https?:/i.test(url))
|
|
457
|
+
throw new VerifyError('INVALID_ARGUMENT', 'On web, openUrl() takes an http(s) URL; deep links such as myapp://orders/42 open only on iOS and Android.');
|
|
458
|
+
await this.perform({ kind: 'open_url', url });
|
|
459
|
+
}
|
|
460
|
+
/** Launches an installed app by bundle ID or package name. Devices only. */
|
|
461
|
+
async launchApp(bundleId) {
|
|
462
|
+
if (this.platform === 'web')
|
|
463
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'launchApp() is available only on iOS and Android sessions; on web, use openUrl() or navigate().');
|
|
464
|
+
if (typeof bundleId !== 'string' || !/^[A-Za-z0-9._-]{1,255}$/.test(bundleId))
|
|
465
|
+
throw new VerifyError('INVALID_ARGUMENT', 'launchApp() takes a bundle ID or package name.');
|
|
466
|
+
await this.act('launch', { bundle_id: bundleId });
|
|
467
|
+
}
|
|
468
|
+
/** Asks Revyl's grounding model where an element is on the current screen, in device units. */
|
|
469
|
+
async locate(target) {
|
|
470
|
+
const { x, y } = await this.resolve(checkLocation({ target }));
|
|
471
|
+
return { x, y };
|
|
472
|
+
}
|
|
473
|
+
/** The screen size in device units: iOS points, Android and Web pixels. */
|
|
474
|
+
screenSize() {
|
|
475
|
+
return this.readScreenSize();
|
|
476
|
+
}
|
|
477
|
+
async readScreenSize(signal) {
|
|
478
|
+
if (this.size)
|
|
479
|
+
return this.size;
|
|
480
|
+
if (this.platform !== 'web') {
|
|
481
|
+
const health = parseJson(await this.worker('GET', 'health', { retry: true, ...(signal ? { signal } : {}) }), 'health');
|
|
482
|
+
if (Number.isInteger(health.screen_width) && Number.isInteger(health.screen_height) && health.screen_width > 0 && health.screen_height > 0) {
|
|
483
|
+
this.size = { width: health.screen_width, height: health.screen_height };
|
|
484
|
+
return this.size;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const size = pngSize((await this.capture(signal)).png);
|
|
488
|
+
if (!size)
|
|
489
|
+
throw new VerifyError('ACTION_FAILED', 'Revyl returned a screenshot that is not a PNG image.');
|
|
490
|
+
this.size = size;
|
|
491
|
+
return size;
|
|
492
|
+
}
|
|
493
|
+
async capture(signal) {
|
|
494
|
+
const response = await this.worker('GET', 'screenshot', { retry: true, ...(signal ? { signal } : {}) });
|
|
495
|
+
if (!pngSize(response.body))
|
|
496
|
+
throw new VerifyError('ACTION_FAILED', 'Revyl returned a screenshot that is not a PNG image.');
|
|
497
|
+
const screenToken = this.platform === 'web' ? response.headers.get('x-revyl-screen-token') ?? undefined : undefined;
|
|
498
|
+
return { png: response.body, ...(screenToken ? { screenToken } : {}) };
|
|
499
|
+
}
|
|
500
|
+
async perform(action, screenToken, signal) {
|
|
501
|
+
switch (action.kind) {
|
|
502
|
+
case 'tap': {
|
|
503
|
+
if ('target' in action.at && this.platform !== 'web') {
|
|
504
|
+
signal?.throwIfAborted();
|
|
505
|
+
const result = parseJson(await this.worker('POST', 'tap_target', { body: { target: action.at.target, ...(this.id ? { session_id: this.id } : {}) }, ...(signal ? { signal } : {}) }), 'tap_target');
|
|
506
|
+
if (result.found === false)
|
|
507
|
+
throw new VerifyError('ACTION_FAILED', `Revyl could not find "${action.at.target}" on the screen.`);
|
|
508
|
+
if (result.success !== true || typeof result.x !== 'number' || typeof result.y !== 'number')
|
|
509
|
+
throw new VerifyError('ACTION_FAILED', `tap failed: ${result.error ?? 'no reason given'}`);
|
|
510
|
+
return { x: Math.round(result.x), y: Math.round(result.y) };
|
|
511
|
+
}
|
|
512
|
+
const point = await this.resolve(action.at, screenToken, signal);
|
|
513
|
+
await this.act('tap', pointBody(point, action.at), signal);
|
|
514
|
+
return { x: point.x, y: point.y };
|
|
515
|
+
}
|
|
516
|
+
case 'type': {
|
|
517
|
+
const point = await this.resolve(action.at, screenToken, signal);
|
|
518
|
+
await this.act('input', { ...pointBody(point, action.at), text: action.text, clear_first: action.clear }, signal);
|
|
519
|
+
return { x: point.x, y: point.y };
|
|
520
|
+
}
|
|
521
|
+
case 'swipe': {
|
|
522
|
+
const point = action.from ? await this.resolve(action.from, screenToken, signal) : await this.readScreenSize(signal).then((size) => ({ x: Math.floor(size.width / 2), y: Math.floor(size.height / 2) }));
|
|
523
|
+
const direction = this.platform === 'web' ? { up: 'down', down: 'up', left: 'right', right: 'left' }[action.direction] : action.direction;
|
|
524
|
+
await this.act('swipe', { ...pointBody(point, action.from), direction, duration_ms: 500 }, signal);
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
case 'key':
|
|
528
|
+
await this.act('key', { key: this.platform === 'web' ? (action.key === 'enter' ? 'Enter' : 'Backspace') : action.key.toUpperCase() }, signal);
|
|
529
|
+
return undefined;
|
|
530
|
+
case 'back':
|
|
531
|
+
if (this.platform === 'ios')
|
|
532
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'back() is not available on iOS; tap the on-screen back control.');
|
|
533
|
+
await this.act('back', undefined, signal);
|
|
534
|
+
return undefined;
|
|
535
|
+
case 'open_url':
|
|
536
|
+
signal?.throwIfAborted();
|
|
537
|
+
if (this.platform === 'web')
|
|
538
|
+
await this.navigateTo(action.url, signal);
|
|
539
|
+
else
|
|
540
|
+
await this.act('open_url', { url: action.url }, signal);
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
async resolve(at, screenToken, signal) {
|
|
545
|
+
if ('x' in at)
|
|
546
|
+
return { x: at.x, y: at.y, ...(screenToken ? { screenToken } : {}) };
|
|
547
|
+
const result = parseJson(await this.worker('POST', 'resolve_target', { body: { target: at.target, ...(this.id ? { session_id: this.id } : {}), ...(screenToken ? { screen_token: screenToken } : {}) }, ...(signal ? { signal } : {}) }), 'resolve_target');
|
|
548
|
+
if (result.found !== true || typeof result.x !== 'number' || typeof result.y !== 'number') {
|
|
549
|
+
throw new VerifyError('ACTION_FAILED', `Revyl could not find "${at.target}" on the screen${result.error && this.platform !== 'web' ? `: ${result.error}` : ''}.`);
|
|
550
|
+
}
|
|
551
|
+
return { x: Math.round(result.x), y: Math.round(result.y), ...(result.screen_token ? { screenToken: result.screen_token } : {}) };
|
|
552
|
+
}
|
|
553
|
+
async act(action, body, signal) {
|
|
554
|
+
signal?.throwIfAborted();
|
|
555
|
+
const result = parseJson(await this.worker('POST', action, { body: body ?? {}, ...(signal ? { signal } : {}) }), action);
|
|
556
|
+
if (result.success !== true)
|
|
557
|
+
throw new VerifyError('ACTION_FAILED', `${action} failed on the ${this.target} session: ${result.error ?? 'no reason given'}. See ${this.viewerUrl}`);
|
|
558
|
+
}
|
|
368
559
|
async launch(bundleId) {
|
|
369
560
|
const response = await this.worker('POST', 'launch', { body: { bundle_id: bundleId } });
|
|
370
561
|
const result = parseJson(response, 'launch');
|
|
@@ -413,10 +604,20 @@ export class DeviceSession {
|
|
|
413
604
|
...(this.parent.expiresAt ? [AbortSignal.timeout(Math.max(0, this.parent.expiresAt.getTime() - Date.now()))] : []),
|
|
414
605
|
...(options.signal ? [options.signal] : []),
|
|
415
606
|
]) : options.signal;
|
|
416
|
-
const
|
|
417
|
-
|
|
607
|
+
const { allow, ...request } = options;
|
|
608
|
+
let response;
|
|
609
|
+
try {
|
|
610
|
+
response = await this.client.worker(this.workflowRunId, action, { method, ...request, signal, ...(this.attribution ? { headers: this.attribution } : {}) });
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
if (method === 'POST' && action !== 'resolve_target' && options.signal?.aborted && isVerifyError(error) && error.code === 'TIMEOUT') {
|
|
614
|
+
throw new VerifyError('ACTION_UNCERTAIN', `The step deadline passed while ${action} was in flight, so it may have been applied. Verify did not repeat it. See ${this.viewerUrl}`, { cause: error });
|
|
615
|
+
}
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
if ((response.status >= 200 && response.status < 300) || allow?.(response))
|
|
418
619
|
return response;
|
|
419
|
-
if (action === 'open_url') {
|
|
620
|
+
if (action === 'open_url' && this.platform === 'web') {
|
|
420
621
|
const error = this.workerError(method, action, response);
|
|
421
622
|
const reason = responseRefusalReason(response);
|
|
422
623
|
const message = error.code === 'ACTION_UNCERTAIN' && reason === 'request_timeout'
|
|
@@ -439,6 +640,9 @@ export class DeviceSession {
|
|
|
439
640
|
}
|
|
440
641
|
return new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${baseAction} is not available on ${this.platform} sessions (${reason}).`);
|
|
441
642
|
}
|
|
643
|
+
if (WEB_TARGET_ERRORS.has(code ?? detail ?? '')) {
|
|
644
|
+
return new VerifyError('ACTION_FAILED', `${baseAction} could not act on the screen (${code ?? detail}). Take a fresh screenshot and try again. See ${this.viewerUrl}`);
|
|
645
|
+
}
|
|
442
646
|
if (response.status === 404 || response.status === 410) {
|
|
443
647
|
return new VerifyError('SESSION_ENDED', `The ${this.target} session is no longer reachable (${reason}). See ${this.viewerUrl}`);
|
|
444
648
|
}
|
|
@@ -554,6 +758,20 @@ export function pngSize(bytes) {
|
|
|
554
758
|
const height = view.getUint32(20);
|
|
555
759
|
return width > 0 && height > 0 ? { width, height } : undefined;
|
|
556
760
|
}
|
|
761
|
+
function checkLocation(at) {
|
|
762
|
+
if (at && typeof at === 'object' && 'target' in at) {
|
|
763
|
+
if (typeof at.target !== 'string' || at.target.trim() === '' || at.target.length > 500)
|
|
764
|
+
throw new VerifyError('INVALID_ARGUMENT', 'A target is a description of 1 to 500 characters.');
|
|
765
|
+
return { target: at.target.trim() };
|
|
766
|
+
}
|
|
767
|
+
if (!at || typeof at !== 'object' || !Number.isInteger(at.x) || !Number.isInteger(at.y) || at.x < 0 || at.y < 0) {
|
|
768
|
+
throw new VerifyError('INVALID_ARGUMENT', 'A location is { x, y } in non-negative whole device units, or { target: "description" }.');
|
|
769
|
+
}
|
|
770
|
+
return { x: at.x, y: at.y };
|
|
771
|
+
}
|
|
772
|
+
function pointBody(point, at) {
|
|
773
|
+
return { x: point.x, y: point.y, ...(point.screenToken ? { screen_token: point.screenToken } : {}), ...(at && 'target' in at ? { target: at.target } : {}) };
|
|
774
|
+
}
|
|
557
775
|
function formatDuration(ms) {
|
|
558
776
|
return ms < 60_000 ? `${(ms / 1_000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1_000)}s`;
|
|
559
777
|
}
|
package/docs/README.md
CHANGED
|
@@ -8,10 +8,11 @@ These files ship inside the npm package at `node_modules/@revyl/verify/docs/`, s
|
|
|
8
8
|
| [getting-started.md](getting-started.md) | Install Verify, authenticate, and run a first script |
|
|
9
9
|
| [api.md](api.md) | Look up session lifecycle, agent steps, screenshots, commands, files, and processes |
|
|
10
10
|
| [configuration.md](configuration.md) | Look up every option in `verify.config.ts`, the environment variables, and the CLI |
|
|
11
|
-
| [web.md](web.md) | Open a web app in a Revyl cloud browser |
|
|
12
|
-
| [mobile.md](mobile.md) | Open an iOS or Android app on a Revyl cloud device |
|
|
13
|
-
| [computers.md](computers.md) | Open a Mac
|
|
11
|
+
| [web.md](web.md) | Open a web app in a Revyl cloud browser, and act on it with exact actions |
|
|
12
|
+
| [mobile.md](mobile.md) | Open an iOS or Android app on a Revyl cloud device, and act on it with exact actions |
|
|
13
|
+
| [computers.md](computers.md) | Open a Mac computer for commands, files, processes, screenshots, and optional recording metadata |
|
|
14
14
|
| [ci.md](ci.md) | Call Verify from CI or from a test runner you already use |
|
|
15
|
+
| [own-model.md](own-model.md) | Run instructions and validations on your own model, such as Claude, GPT or a local model |
|
|
15
16
|
| [errors.md](errors.md) | Understand an error code and fix it, or troubleshoot a common problem |
|
|
16
17
|
| [agents.md](agents.md) | Use Verify as a coding agent |
|
|
17
18
|
|
package/docs/agents.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# For coding agents
|
|
2
2
|
|
|
3
|
-
This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that use Verify to check an app on a cloud browser/device or run commands and files on a Mac
|
|
3
|
+
This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that use Verify to check an app on a cloud browser/device or run commands and files on a Mac computer. The rest of this folder describes the API in full; this page is the workflow.
|
|
4
4
|
|
|
5
5
|
## Workflow
|
|
6
6
|
|
|
7
|
-
1. Read `verify.config.ts` to learn the target names and their platforms. If there is none, run `npx @revyl/verify init --platform
|
|
7
|
+
1. Read `verify.config.ts` to learn the target names and their platforms. If there is none, run `npx @revyl/verify init --platform ios` or `--platform android` (without `--app-id` they open the built-in Settings app), `npx @revyl/verify init --platform web --app-id <id> --url <https-url>`, or `npx @revyl/verify init --platform mac`. You can also pass a target object to `open()`.
|
|
8
8
|
2. Check the credentials once with `npx @revyl/verify whoami`. It starts no session.
|
|
9
9
|
3. Write a short script, or extend an existing one, that opens one target, runs the steps you need, and closes:
|
|
10
10
|
```ts
|
|
@@ -24,11 +24,11 @@ This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that
|
|
|
24
24
|
|
|
25
25
|
## Rules
|
|
26
26
|
|
|
27
|
-
Mac
|
|
27
|
+
Mac computers are early access. Ask support@revyl.ai about access before starting a computer; do not repeat an uncertain start/stop to probe availability.
|
|
28
28
|
|
|
29
|
-
For computer work, use structured `exec(['program', 'arg'])`, `readFile`, `writeFile`, `stat`, and run-owned handles from `processes()`/`terminate(id)`. Check command output with ordinary assertions and close in `finally`; screenshots are supported, but instruction/validation are not. Do not fall back to agent steps on computers or shell/file methods on mobile/web. See [computers.md](computers.md) for limits.
|
|
29
|
+
For computer work, use structured `exec(['program', 'arg'])`, `readFile`, `writeFile`, `stat`, and run-owned handles from `processes()`/`terminate(id)`. Check command output with ordinary assertions and close in `finally`; screenshots are supported, but instruction/validation are not. Do not fall back to agent steps on computers or shell/file methods on mobile/web. See [computers.md](computers.md) for limits. Starting a computer is a paid external action and needs the same approval as starting a device.
|
|
30
30
|
|
|
31
|
-
Computers have a hard
|
|
31
|
+
Computers have a hard lifetime from create (two hours today), not from readiness or activity; `expiresAt` is the authoritative deadline. `expiresAt` is optional on older APIs; expiry does not prevent close. After terminal state and confirmed release, `recording()` can return private signed metadata or null, including after close. Never log or share its signed URL/query. Do not automatically wait for recordings or retry `COMPUTER_LIMIT` starts: queued work (including iOS) or the concurrency limit may be responsible.
|
|
32
32
|
|
|
33
33
|
- Write steps the way you would brief a person: what to do, or what should be true, in one sentence. Name visible things ("the blue Save button in the footer"), not implementation details.
|
|
34
34
|
- Use `instruction` for doing and `validation` for checking. Do not ask an instruction to check something, or a validation to change anything.
|
package/docs/api.md
CHANGED
|
@@ -6,22 +6,21 @@ import { attach, open, defineConfig, VerifyError, isVerifyError } from '@revyl/v
|
|
|
6
6
|
|
|
7
7
|
## open(target, options?)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Starts a Revyl cloud browser, device, or Mac computer. Mobile first-screen readiness is [best-effort](mobile.md#steps), not a foreground/rendered-readiness guarantee. Computer starts retain a 10-minute default timeout; an unclaimed start may fail after 120 seconds with no-host context.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
Both computer operating systems are early access. Ask support@revyl.ai about access.
|
|
11
|
+
Mac computers are early access. Ask support@revyl.ai about access.
|
|
14
12
|
|
|
15
13
|
```ts
|
|
16
14
|
const web = await open('web'); // a target named in verify.config.ts
|
|
17
15
|
const ios = await open({ platform: 'ios', app: { appId: '…' } }); // or a target object, no config needed
|
|
18
16
|
const mac = await open({ platform: 'mac' }); // computer, no app needed
|
|
19
|
-
const linux = await open({ platform: 'linux' }); // same computer surface
|
|
20
17
|
```
|
|
21
18
|
|
|
22
19
|
- `target`: a target name from `verify.config.ts`, or a target object with the same shape as a config entry (see [configuration.md](configuration.md)). Verify reads the nearest `verify.config.ts` at or above the current folder.
|
|
23
20
|
- `options.config`: the path of a config file to read instead.
|
|
24
21
|
- `options.log`: where progress lines go. By default Verify writes the session link and each step's outcome to stderr, prefixed with `[verify]`. Pass a function to receive the lines yourself, or `false` to turn them off.
|
|
22
|
+
- `options.model`: runs every instruction and validation on your own model instead of Revyl's agent; see [Your own model](#your-own-model). Browsers and devices only; computers throw `NOT_AVAILABLE_ON_PLATFORM`.
|
|
23
|
+
- `options.agent`: the same with a custom agent; see [Custom agents](#custom-agents). Pass `model` or `agent`, not both.
|
|
25
24
|
|
|
26
25
|
`open` needs credentials: `REVYL_API_KEY` or a Revyl CLI login. It throws `AUTH_MISSING` without them, `CONFIG_NOT_FOUND` or `CONFIG_INVALID` for config problems, and `SESSION_START_FAILED` or `TIMEOUT` when the browser or device does not start.
|
|
27
26
|
|
|
@@ -53,7 +52,7 @@ The attachment, hosted-child and navigation APIs below are included in the TypeS
|
|
|
53
52
|
|
|
54
53
|
`open()` and `computer.open()` return the extended `OpenedSession` interface. The original `Session` interface keeps its existing required members, so applications implementing or mocking it do not need to add the new methods. Use `OpenedSession` when explicitly annotating a value that calls `report`, `navigate` or `open`.
|
|
55
54
|
|
|
56
|
-
Attaches to an active Mac
|
|
55
|
+
Attaches to an active Mac computer in your organization without creating a new one. `options.log` controls progress. The returned computer owns cleanup by default: `close()` stops it, and automatic cleanup also applies. Attaching requires a valid expiry and does not extend the lifetime. Invalid, inaccessible, inactive or expired computers fail without sending a stop request.
|
|
57
56
|
|
|
58
57
|
When attaching to a computer you do not own, such as a CI runner, pass `attach(computerId, { stopOnClose: false })`. The attachment is not registered for automatic cleanup. Its `close()` still cancels pending child starts and gives hosted children the same five-second cleanup phase, but never stops the computer; its owner must release it. Child cleanup failures remain observable. This option is explicit and is never inferred from the environment.
|
|
59
58
|
|
|
@@ -61,7 +60,7 @@ When attaching to a computer you do not own, such as a CI runner, pass `attach(c
|
|
|
61
60
|
|
|
62
61
|
Starts a child app session inside this computer, returning the normal session API. It inherits the parent's authenticated client, logging and step timeout; callers cannot route a child to another backend. Both `target.app.appId` and `target.app.buildId` are required. Web accepts an existing external HTTPS build, plus optional viewport/sign-in settings; native builds use the computer's fixed device profile and accept no bundle, artifact or device override. Named targets and initial URL overrides are not supported here.
|
|
63
62
|
|
|
64
|
-
Mac supports Web and iOS children.
|
|
63
|
+
Mac supports Web and iOS children. The server remains authoritative for access, capability and capacity. Children require and share the parent's known deadline. Closing a child leaves the parent running; closing the parent cancels pending starts and gives child cleanup at most five seconds total before requesting confirmed computer release. The parent stop also stops any remaining children, so a slow child cannot prevent that request. A failed child start never closes the parent.
|
|
65
64
|
|
|
66
65
|
```ts
|
|
67
66
|
const computer = await attach(existingComputerId, { log: false });
|
|
@@ -83,6 +82,41 @@ This requires an already-running service on the computer. The initial Web build
|
|
|
83
82
|
|
|
84
83
|
Web only, hosted or normal. Sends one deterministic `open_url` action and checks the returned navigation outcome. Accepts an HTTP(S) URL without credentials, whitespace, control characters or backslashes. Loopback addresses are accepted on every Web session: a hosted browser can reach its parent computer's loopback address; a normal cloud browser's localhost is its own runtime, not your machine. A cold development server may need a warm-up request before navigation succeeds; after an uncertain timeout, re-navigating is the caller's choice because the original request may still commit. The runtime is responsible for enforcing its navigation network policy. Navigation is not replayed by Verify and does not run an agent step. It resolves with no value, or throws a typed error. Computer and native sessions reject it locally.
|
|
85
84
|
|
|
85
|
+
## Exact actions
|
|
86
|
+
|
|
87
|
+
**Since 0.4.0. Early access.** In TypeScript (`@revyl/verify` 0.4.0), Python (`revyl-verify` 0.2.0) and Go (v0.3.0); the [Python](https://github.com/RevylAI/verify/blob/v0.4.0/python/README.md#exact-actions) and [Go](https://github.com/RevylAI/verify/blob/v0.4.0/go/README.md#exact-actions) READMEs show them with each language's names. Web, iOS and Android sessions take device actions that need no agent.
|
|
88
|
+
|
|
89
|
+
| Call | Web | iOS | Android | Mac computer |
|
|
90
|
+
| --- | --- | --- | --- | --- |
|
|
91
|
+
| `tap`, `type`, `swipe`, `pressKey`, `locate`, `screenSize` | Yes | Yes | Yes | No, use `exec()` and files |
|
|
92
|
+
| `back()` | Yes, browser history | No, tap the on-screen back control | Yes, the system back button | No |
|
|
93
|
+
| `openUrl(url)` | Yes, loads an http(s) URL in the tab | Yes, a URL or deep link | Yes, a URL or deep link | No |
|
|
94
|
+
| `launchApp(bundleId)` | No, use `openUrl` or `navigate` | Yes | Yes | No |
|
|
95
|
+
| `navigate(url)` | Yes | No, use `openUrl` | No, use `openUrl` | No |
|
|
96
|
+
| `instruction`, `validation`, your own model | Yes | Yes | Yes | No, use `exec()` |
|
|
97
|
+
| `screenshot()` | Yes | Yes | Yes | Yes |
|
|
98
|
+
| `exec`, files, processes | No, open a Mac computer | No, open a Mac computer | No, open a Mac computer | Yes |
|
|
99
|
+
|
|
100
|
+
Coordinates are in iOS points, Android pixels and Web CSS pixels, from the top-left corner. A call a platform doesn't support throws `NOT_AVAILABLE_ON_PLATFORM`, and its message names the alternative.
|
|
101
|
+
|
|
102
|
+
A location is `{ x, y }` in device units (iOS points, Android and Web pixels, from the top-left corner; `screenSize()` gives the bounds) or `{ target: 'Login button' }`, which Revyl's grounding model finds on the current screen.
|
|
103
|
+
|
|
104
|
+
| Call | What it does |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `tap(at)` | Taps and resolves with the point tapped |
|
|
107
|
+
| `type(at, text, { clear })` | Taps a text field and types into it, clearing it first unless `clear` is `false` |
|
|
108
|
+
| `swipe(direction, { from })` | Swipes from a location or the screen's center; `'up'` moves the finger up, which scrolls content down |
|
|
109
|
+
| `pressKey('enter' \| 'backspace')` | Presses a key on the focused field |
|
|
110
|
+
| `back()` | Browser history on Web, the back button on Android; iOS throws `NOT_AVAILABLE_ON_PLATFORM` |
|
|
111
|
+
| `openUrl(url)` | Opens a URL or deep link on a device, or loads a URL in a browser |
|
|
112
|
+
| `launchApp(bundleId)` | Launches an installed app on a device |
|
|
113
|
+
| `locate(target)` | Resolves with Revyl's best match on the current screen, without acting |
|
|
114
|
+
| `screenSize()` | Resolves with `{ width, height }` in device units |
|
|
115
|
+
|
|
116
|
+
A failed action throws `ACTION_FAILED`, including when Revyl cannot find a target or nothing at a point can take the action. One whose outcome Revyl can't confirm throws `ACTION_UNCERTAIN` or `TIMEOUT`, so check the session before acting again. Actions are never repeated. If Verify can't reach Revyl, the action fails with `API_ERROR` and isn't resent either, so check the session before trying it again.
|
|
117
|
+
|
|
118
|
+
Exact actions return when Revyl has accepted them, not when the screen has finished changing. After `swipe`, `openUrl` or `launchApp`, let the screen settle or check it before you `locate` or tap coordinates. `locate` returns Revyl's best match on the current screen and can return a point for an element that is off screen, so bring the element on screen first and check the point before you use it.
|
|
119
|
+
|
|
86
120
|
## session.report()
|
|
87
121
|
|
|
88
122
|
Returns authenticated JSON metadata: `/execution/device-sessions/{id}` for normal and hosted app sessions, or the computer report for computers. It works after close, but fails locally with `NOT_FOUND` when no session ID is known. It does not download recordings or turn a device stop acknowledgment into proof of physical release. Treat returned metadata as private.
|
|
@@ -116,8 +150,9 @@ interface StepResult {
|
|
|
116
150
|
type: 'instruction' | 'validation';
|
|
117
151
|
text: string; // the step as sent, trimmed
|
|
118
152
|
passed: boolean;
|
|
119
|
-
reason?: string; // Revyl's explanation,
|
|
153
|
+
reason?: string; // Revyl's explanation, or your model's on a local agent
|
|
120
154
|
durationMs: number;
|
|
155
|
+
model?: ModelRun; // set when the step ran on your model
|
|
121
156
|
}
|
|
122
157
|
```
|
|
123
158
|
|
|
@@ -125,6 +160,47 @@ interface StepResult {
|
|
|
125
160
|
|
|
126
161
|
When inspecting `revyl device report --session-id <id>`, read validation verdicts from the step's `effective_status` and `validation_result`, with `validations_passed` / `total_validations` for the validation summary. `passed_steps` / `failed_steps` and transport `status` are not enough: a successfully executed validation can have a false verdict. The SDK's `StepResult.passed` reflects that verdict, not just transport success.
|
|
127
162
|
|
|
163
|
+
## Your own model
|
|
164
|
+
|
|
165
|
+
**Since 0.4.0. Early access.** In TypeScript (`@revyl/verify` 0.4.0), Python (`revyl-verify` 0.2.0) and Go (v0.3.0); the [Python](https://github.com/RevylAI/verify/blob/v0.4.0/python/README.md#your-own-model) and [Go](https://github.com/RevylAI/verify/blob/v0.4.0/go/README.md#your-own-model) READMEs show them with each language's names. Runs instructions and validations on a model you choose, in your process, instead of Revyl's agent. Revyl still provides the browser or device. Leave `model` out and Revyl's agent runs the step, as before.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const session = await open('ios', { model: 'anthropic/claude-sonnet-5-5' }); // every step on your model
|
|
169
|
+
await session.instruction('Open General, then About');
|
|
170
|
+
await session.validation('The screen shows the iOS version');
|
|
171
|
+
await session.instruction('Open Privacy', { model: 'openai/gpt-5.5' }); // or one step
|
|
172
|
+
|
|
173
|
+
// Your own OpenAI-compatible endpoint: vLLM, LiteLLM, a gateway
|
|
174
|
+
const custom = await open('ios', {
|
|
175
|
+
model: { id: 'qwen-3.8-27b', baseURL: 'https://api.cerebras.ai/v1', apiKey: process.env.CEREBRAS_API_KEY },
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`options.model` is a `'provider/model'` string, with the key read from the provider's usual environment variable (and `ANTHROPIC_BASE_URL` or `OPENAI_BASE_URL` for a proxy), or `{ id, baseURL, apiKey?, headers?, provider?, grounding? }` for any OpenAI-compatible endpoint. [Use your own model](own-model.md) has the provider table, which providers are verified live, how to pick a model, what Revyl still sees, the limits and troubleshooting. A missing key throws `MODEL_KEY_MISSING` before any session starts; an unknown prefix throws `INVALID_ARGUMENT`. The model must accept images and tool calls.
|
|
180
|
+
|
|
181
|
+
The object form's key comes only from `apiKey`, never from the environment.
|
|
182
|
+
|
|
183
|
+
`StepResult.model` reports `{ id, grounding, turns, inputTokens, outputTokens }`, with each turn's action, model and action time, and token counts. Provider failures throw `MODEL_ERROR` with the key redacted; model calls are retried up to twice on rate limits and server errors. An instruction stops after 20 turns, and the step's `timeout` bounds the whole step.
|
|
184
|
+
|
|
185
|
+
## Custom agents
|
|
186
|
+
|
|
187
|
+
For more control, build the agent yourself with `localAgent()` and pass it as `agent` on `open()` or a step:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { localAgent, anthropic, openaiCompatible } from '@revyl/verify';
|
|
191
|
+
|
|
192
|
+
const agent = localAgent({
|
|
193
|
+
model: openaiCompatible({ baseURL: 'http://localhost:11434/v1', model: 'qwen3-vl:8b' }),
|
|
194
|
+
grounding: 'model',
|
|
195
|
+
maxTurns: 10,
|
|
196
|
+
});
|
|
197
|
+
const session = await open('ios', { agent });
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
- `model`: a `ModelAdapter`. `anthropic({ model, apiKey, baseURL, maxTokens, maxRetries })` uses `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL` for what you leave out. `openaiCompatible({ model, baseURL, apiKey, headers, provider, maxTokens, maxRetries })` uses `OPENAI_BASE_URL` and `OPENAI_API_KEY` only when `baseURL` is left out, so a key never reaches another provider; it then sends OpenAI's `max_completion_tokens`. To wrap another SDK or runtime, implement `{ name, complete(request, signal) }`: it gets the system prompt, text, screenshot and tools, and returns the chosen tool call and token usage.
|
|
201
|
+
- `grounding`: `'revyl'` (default) as above. `'model'` has the model give screenshot coordinates instead, so Revyl receives only taps, typing and swipes, never element descriptions; it needs a model that points accurately.
|
|
202
|
+
- `maxTurns` (default 20, up to 100), `modelTimeout` per model call (default 60000), `maxImageSide` for screenshots sent to the model (default 1280).
|
|
203
|
+
|
|
128
204
|
## session.screenshot(options?)
|
|
129
205
|
|
|
130
206
|
Resolves with a PNG of the screen as a `Uint8Array`. With `options.path`, also writes it there, creating folders as needed.
|
|
@@ -137,7 +213,7 @@ For owning computers, cleanup must be confirmed; a terminal state alone does not
|
|
|
137
213
|
|
|
138
214
|
## Computer commands, files, and processes
|
|
139
215
|
|
|
140
|
-
Mac
|
|
216
|
+
Mac sessions provide `exec(argv: string[], options?)`, `readFile(path, options?)` returning `Buffer`, `writeFile(path, string | Uint8Array, options?)`, `stat(path, options?)` returning `{ kind, size }`, `processes(options?)` returning `{ id, state, exitCode? }[]`, and `terminate(id, options?)`. They throw `NOT_AVAILABLE_ON_PLATFORM` on web/iOS/Android. See [computers.md](computers.md) for complete signatures, limits, timeouts, and examples. Both support `screenshot()`; instruction/validation do not run on computers.
|
|
141
217
|
|
|
142
218
|
### `session.recording(): Promise<ComputerRecording | null>`
|
|
143
219
|
|
|
@@ -151,17 +227,17 @@ Computers only. First reads the report and requires terminal state plus confirme
|
|
|
151
227
|
| --- | --- |
|
|
152
228
|
| `viewerUrl` | The session in Revyl: a live view while it runs, its recording/report after. Use `revyl device report --session-id <id>` for web/mobile, or `revyl computer report <id>` for computers. |
|
|
153
229
|
| `id` | Revyl's session id |
|
|
154
|
-
| `platform` | `'web'`, `'ios'`, `'android'`,
|
|
155
|
-
| `expiresAt` | **Since 0.2.0.** Validated API expiry as a `Date`, or undefined for devices and older APIs. Computers have a hard
|
|
230
|
+
| `platform` | `'web'`, `'ios'`, `'android'`, or `'mac'` |
|
|
231
|
+
| `expiresAt` | **Since 0.2.0.** Validated API expiry as a `Date`, or undefined for devices and older APIs. Computers have a hard lifetime from create (two hours today); this value is the authoritative deadline. Operations/screenshot observation are bounded by expiry; close and recording remain possible. |
|
|
156
232
|
| `target` | The target name, or the platform for a target object |
|
|
157
233
|
| `closed` | Whether `close()` was called |
|
|
158
234
|
|
|
159
235
|
## Rules worth knowing
|
|
160
236
|
|
|
161
237
|
- **One step at a time.** Await each step before starting the next on the same session; a second concurrent step throws `INVALID_ARGUMENT`. Separate sessions can run in parallel.
|
|
162
|
-
- **No hidden retries.** Verify never repeats a step or a device action. A step whose outcome is unknown because the connection failed mid-request throws `ACTION_UNCERTAIN`.
|
|
238
|
+
- **No hidden retries.** Verify never repeats a step or a device action. A local agent retries only its own model calls. A step whose outcome is unknown because the connection failed mid-request throws `ACTION_UNCERTAIN`.
|
|
163
239
|
- **No memory between steps.** Each step is independent: Revyl does not carry values from one step to the next. Put everything a step needs into its text.
|
|
164
|
-
- **No replay or cache.** Every step runs
|
|
240
|
+
- **No replay or cache.** Every step runs its agent again, every time.
|
|
165
241
|
|
|
166
242
|
## Cleanup
|
|
167
243
|
|