verikun 0.5.0 → 0.8.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 +123 -4
- package/dist/agent/cost.js +21 -10
- package/dist/agent/engine.js +26 -8
- package/dist/agent/grammar.js +6 -1
- package/dist/agent/openai.js +243 -0
- package/dist/agent/remote.js +166 -0
- package/dist/args.js +2 -0
- package/dist/bin/verikun.js +0 -0
- package/dist/cli.js +381 -98
- package/dist/drivers/adb.js +12 -0
- package/dist/drivers/ios.js +7 -0
- package/dist/drivers/simctl.js +156 -0
- package/dist/report.js +93 -3
- package/dist/rpc.js +46 -0
- package/dist/run.js +101 -1
- package/dist/server.js +349 -0
- package/dist/suite.js +152 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,6 +80,7 @@ vk screenshot # -> ./.verikun/screen.png
|
|
|
80
80
|
| `screenshot [--out path] [--more] [--max px] [--full] [--json]` | Save a PNG (default `./.verikun/screen.png`); prints the path. [Downscaled](#screenshots) to a 700px longest edge by default to save tokens; `--more` bumps detail, `--max px` sets an exact cap, `--full` keeps the original. |
|
|
81
81
|
| `launch <app> [--clear] [--no-restart]` / `stop <app>` | App lifecycle by package id (Android) / bundle id (iOS). `launch` **restarts by default** — it force-stops the app first (a no-op if it isn't running) so a rerun starts fresh instead of resurfacing a still-running instance's current screen; `--no-restart` skips that. `--clear` also wipes the app's local data (login/session, prefs, cache) for a fresh-install start. |
|
|
82
82
|
| `clear <app>` | Wipe the app's locally stored data — login/session, preferences, caches — resetting it to a just-installed state (Android `pm clear`, which also force-stops the app). iOS unsupported: there is no per-app data reset. |
|
|
83
|
+
| `install <app.apk\|.ipa> [--server url]` | Install a build on the device (`adb install -r` / `idb install`). With `--server`, the file is uploaded to a remote [`vk server`](#remote-devices--vk-server) started with `--allow-install` (single-file `.apk`/`.ipa`, sha256-verified). |
|
|
83
84
|
|
|
84
85
|
### Batch
|
|
85
86
|
| Command | Description |
|
|
@@ -89,7 +90,13 @@ vk screenshot # -> ./.verikun/screen.png
|
|
|
89
90
|
### AI
|
|
90
91
|
| Command | Description |
|
|
91
92
|
|---|---|
|
|
92
|
-
| `ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--cost-override in/out] [--effort e] [--package pkg] [--app-build id] [--show-plan] [--recompile] [--json]` | Run a plain-English test: compile it to a deterministic plan once, replay it model-free, and self-heal failures via the model. Needs `ANTHROPIC_API_KEY
|
|
93
|
+
| `ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--cost-override in/out] [--effort e] [--package pkg] [--app-build id] [--server url] [--show-plan] [--recompile] [--json]` | Run a plain-English test: compile it to a deterministic plan once, replay it model-free, and self-heal failures via the model. Needs `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` (per model). See [AI](#ai--natural-language-tests). |
|
|
94
|
+
| `suite <dir> [--app <id>] [--name n] [--server url] [--json]` (+ all `ai` flags) | Run every `*.md` in `<dir>` as one sequential suite with an overview report and a non-zero exit on failure — the CI gate. See [Suites](#suites--run-a-directory-of-tests). |
|
|
95
|
+
|
|
96
|
+
### Remote
|
|
97
|
+
| Command | Description |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `server [--bind addr] [--port n] [--auth-key k] [--allow-install] [--allow-unsafe-anonymous]` | Expose this machine's connected device to remote verikun clients (`ai`/`suite`/`install --server`). Auth is mandatory unless explicitly disabled; only verikun's validated command grammar is executable. See [Remote devices](#remote-devices--vk-server). |
|
|
93
100
|
|
|
94
101
|
### Environment
|
|
95
102
|
| Command | Description |
|
|
@@ -190,7 +197,7 @@ printf 'launch com.example.app\nassert @home_tab\nrun archive smoke\n' | vk batc
|
|
|
190
197
|
with no model calls on the happy path**. The model is woken only to *repair* a step
|
|
191
198
|
whose selector stops resolving; a green run persists the repaired plan, so the next
|
|
192
199
|
run is free again. That is what keeps a CI suite's steady-state token cost near zero.
|
|
193
|
-
Needs `ANTHROPIC_API_KEY
|
|
200
|
+
Needs `ANTHROPIC_API_KEY` (Claude models) or `OPENAI_API_KEY` (OpenAI models).
|
|
194
201
|
|
|
195
202
|
```sh
|
|
196
203
|
# onboarding.md (plain English):
|
|
@@ -218,11 +225,114 @@ a hard iteration cap and stop early if the screen stops changing.
|
|
|
218
225
|
$3)** or the wall-clock passes **`--timeout` (default 15m)** — so a runaway loop or
|
|
219
226
|
repair can't spend or hang without limit. `--cost-override <input/output>` overrides
|
|
220
227
|
the bundled per-1M price table if it drifts.
|
|
221
|
-
- **`--model`** picks the model (`claude-haiku-4-5` ·
|
|
222
|
-
`claude-opus-4-8` · `claude-fable-5`)
|
|
228
|
+
- **`--model`** picks the model and its provider — Anthropic (`claude-haiku-4-5` ·
|
|
229
|
+
`claude-sonnet-4-6` (default) · `claude-opus-4-8` · `claude-fable-5`) or OpenAI
|
|
230
|
+
(`gpt-5.4-mini` · `gpt-5.4` · `gpt-5.5`), each read from its own key
|
|
231
|
+
(`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`); **`--recompile`** ignores the cache.
|
|
223
232
|
- An `ai` run records like any other flow, so it produces the same JUnit + HTML report —
|
|
224
233
|
with the cost line and any **suggested test improvements** (workarounds the model
|
|
225
234
|
applied, which you can fold back into the prose to stabilize the test and cut tokens).
|
|
235
|
+
- **Review screenshots are inserted automatically.** The compiler adds `screenshot` steps
|
|
236
|
+
around transitions and inside loops, so the report carries a before/after visual trail
|
|
237
|
+
for post-run review. They are dumped for humans, never read back by the model (no token
|
|
238
|
+
cost on replay), and never gate the test — a capture that hiccups is logged and skipped,
|
|
239
|
+
not a failure.
|
|
240
|
+
|
|
241
|
+
## Suites — run a directory of tests
|
|
242
|
+
|
|
243
|
+
`vk suite <dir>` runs every `*.md` in a directory through the [`vk ai`](#ai--natural-language-tests)
|
|
244
|
+
engine, sequentially, against one shared device (local or [remote](#remote-devices--vk-server)):
|
|
245
|
+
|
|
246
|
+
```sh
|
|
247
|
+
vk suite tests/ --app com.example.app # local device
|
|
248
|
+
vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER" # remote device
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
- **Ordering is lexicographic** — prefix files `01-…`, `02-…` to sequence them.
|
|
252
|
+
`README.md` is skipped (it documents the suite, it isn't a test).
|
|
253
|
+
- **Isolation between tests:** with `--app <id>`, the app's data is cleared before
|
|
254
|
+
each test (`pm clear`; iOS degrades to a force-stop since it has no per-app
|
|
255
|
+
reset). Without `--app`, make each test self-isolating (start with
|
|
256
|
+
`launch <pkg> --clear` in the prose).
|
|
257
|
+
- **Each test is a full `vk ai` run** — plan cache, self-healing, cost budget, and
|
|
258
|
+
its own archived JUnit + HTML report under `./.verikun/runs/<id>/`. A test that
|
|
259
|
+
fails (or errors) doesn't stop the suite; the rest still run.
|
|
260
|
+
- **The suite writes an overview** to `./.verikun/suites/<id>/`:
|
|
261
|
+
- **`index.json`** — a stable, `schemaVersion`ed manifest: per-test pass/fail,
|
|
262
|
+
steps, model repairs, cost, duration, and the run id, plus suite totals. This
|
|
263
|
+
is the **output contract for reporting** — upload/publish steps compose over
|
|
264
|
+
it (see the [CI recipe](#ci-recipe)) instead of verikun growing upload plugins.
|
|
265
|
+
- **`index.html`** — a summary page linking every test's `report.html`.
|
|
266
|
+
- **Exit code is the CI gate:** `1` if any test failed, `0` all green, `2` bad/empty
|
|
267
|
+
directory. All `ai` flags (`--model`, `--max-cost-usd`, `--timeout`, …) apply to
|
|
268
|
+
every test; the model's key (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`) is checked
|
|
269
|
+
up front.
|
|
270
|
+
|
|
271
|
+
## Remote devices — `vk server`
|
|
272
|
+
|
|
273
|
+
CI runners don't have your phone plugged into them. `vk server` exposes a
|
|
274
|
+
locally-connected device to remote verikun clients so a **disposable CI runner**
|
|
275
|
+
can drive it — without a self-hosted runner executing arbitrary PR code on the
|
|
276
|
+
machine that owns the device:
|
|
277
|
+
|
|
278
|
+
```sh
|
|
279
|
+
# On the machine with the device attached:
|
|
280
|
+
export VERIKUN_SERVER_AUTH_KEY=$(openssl rand -base64 32) # or let vk generate one
|
|
281
|
+
vk server --allow-install # 127.0.0.1:8391 by default
|
|
282
|
+
vk server --bind 100.64.0.7 --allow-install # expose on a tailnet IP
|
|
283
|
+
|
|
284
|
+
# From anywhere that can reach it:
|
|
285
|
+
export VERIKUN_SERVER=http://100.64.0.7:8391
|
|
286
|
+
export VERIKUN_SERVER_AUTH_KEY=<the same key>
|
|
287
|
+
vk install ./app-debug.apk --server "$VERIKUN_SERVER"
|
|
288
|
+
vk ai onboarding.md --server "$VERIKUN_SERVER"
|
|
289
|
+
vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER"
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Split execution.** The client runs the whole `vk ai` engine — compile, plan
|
|
293
|
+
cache, repairs, the Anthropic key, run recording, suite aggregation — and only
|
|
294
|
+
**validated device commands** cross the network, one HTTP round-trip per command
|
|
295
|
+
(selector auto-wait polls on the server, next to the device). Each step's detail
|
|
296
|
+
(selector, heal tier, resolved element, failure screenshot + hierarchy) returns
|
|
297
|
+
with the response and is spliced into the client's run, so the archived report is
|
|
298
|
+
identical to a local run's.
|
|
299
|
+
|
|
300
|
+
**Security model** (the server is the boundary, not the transport):
|
|
301
|
+
|
|
302
|
+
- **Auth is mandatory.** Pass a key via `--auth-key` / `VERIKUN_SERVER_AUTH_KEY`
|
|
303
|
+
(the env var keeps it out of `ps`), or one is generated and printed at startup.
|
|
304
|
+
Clients send it as a bearer token; comparison is constant-time.
|
|
305
|
+
`--allow-unsafe-anonymous` disables auth loudly — only for networks that are
|
|
306
|
+
themselves the boundary (e.g. a private tailnet), and it cannot be combined
|
|
307
|
+
with a key.
|
|
308
|
+
- **Only the validated grammar runs.** Every `/v1/exec` request passes the same
|
|
309
|
+
`validateNode` gate that guards `vk ai` model repairs: action verbs only
|
|
310
|
+
(`tap`/`text`/`assert`/`launch`/…), never `ui`/`log`, never a shell. The
|
|
311
|
+
device and platform are fixed when the server starts — client flags cannot
|
|
312
|
+
repoint them.
|
|
313
|
+
- **Installs are opt-in.** `POST /v1/install` requires `--allow-install` (a
|
|
314
|
+
read-only server refuses builds), accepts only single-file `.apk`/`.ipa`
|
|
315
|
+
uploads to a server-generated temp path (never a client path), and verifies a
|
|
316
|
+
sha256 of the body.
|
|
317
|
+
- **One run at a time.** A run-token holds the device lock; a second concurrent
|
|
318
|
+
caller gets `409`. The lock is released when the command finishes (so
|
|
319
|
+
`vk install` then `vk suite` chain seamlessly), and an idle lock (5 min
|
|
320
|
+
silent) is taken over, so a crashed CI job can't wedge the device.
|
|
321
|
+
- **Bind is loopback by default.** `--bind <addr>` opts into exposure. For a
|
|
322
|
+
NAT'd box, [Tailscale](https://tailscale.com) is the recommended transport; for
|
|
323
|
+
a public host, terminate TLS in front (the server itself speaks plain HTTP).
|
|
324
|
+
- Failure evidence (screenshots, UI hierarchies) crosses the authenticated
|
|
325
|
+
channel like the rest — same caveat as `vk log`: device output is not redacted.
|
|
326
|
+
|
|
327
|
+
### CI recipe
|
|
328
|
+
|
|
329
|
+
[`.github/workflows/suite.yml`](.github/workflows/suite.yml) is a working
|
|
330
|
+
reference: a plain `ubuntu-latest` job builds verikun, installs the app build on
|
|
331
|
+
the remote device (`vk install --server`), runs `vk suite --server`, uploads
|
|
332
|
+
`.verikun/suites` + `.verikun/runs` as artifacts, and **fails the job when any
|
|
333
|
+
test fails** (the suite's exit code). Publishing anywhere else is a composable
|
|
334
|
+
step over the `index.json` manifest — the workflow includes commented `rclone`
|
|
335
|
+
(Google Drive) and `aws s3 cp` examples.
|
|
226
336
|
|
|
227
337
|
## Selectors
|
|
228
338
|
|
|
@@ -281,6 +391,8 @@ condition as a step in its own right.
|
|
|
281
391
|
| `-d, --device <serial>` | Target a specific device (or `VERIKUN_DEVICE` / `ANDROID_SERIAL`). |
|
|
282
392
|
| `-p, --platform <android\|ios>` | Platform (default `android`). `--ios` / `--android` are shortcuts. |
|
|
283
393
|
| `-j, --json` | Machine-readable output (also serializes errors). |
|
|
394
|
+
| `--server <url>` | For `ai`/`suite`/`install`: run against a remote [`vk server`](#remote-devices--vk-server) (or `VERIKUN_SERVER`). The server's device/platform apply. |
|
|
395
|
+
| `--auth-key <k>` | Key for `--server` (or `VERIKUN_SERVER_AUTH_KEY`, which keeps it out of `ps`). |
|
|
284
396
|
| `--` | End flag parsing, so text/arguments may start with `-`. |
|
|
285
397
|
|
|
286
398
|
## Exit codes
|
|
@@ -317,6 +429,13 @@ safely resample (palette, 16-bit, interlaced) are written through untouched, so
|
|
|
317
429
|
screenshot is never corrupted — only sometimes left full-size (noted on stderr).
|
|
318
430
|
Failure-evidence captures in test-run reports stay full-resolution for debugging.
|
|
319
431
|
|
|
432
|
+
**Read-back vs evidence.** The downscaling above matters when an agent *reads a
|
|
433
|
+
screenshot back into its context* to decide the next action — that is the token cost to
|
|
434
|
+
manage. A screenshot taken purely as **report evidence and never read back** costs nothing
|
|
435
|
+
at runtime, so driving a flow to a report should capture liberally around transitions.
|
|
436
|
+
`vk ai` does this automatically (see [AI](#ai--natural-language-tests)); when driving by
|
|
437
|
+
hand, `vk screenshot` around each screen change and leave the PNG in the report.
|
|
438
|
+
|
|
320
439
|
## How it works
|
|
321
440
|
|
|
322
441
|
```
|
package/dist/agent/cost.js
CHANGED
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CostTracker = exports.DEFAULT_MAX_COST_USD = exports.DEFAULT_MODEL = exports.ALLOWED_MODELS = exports.MODEL_PRICES = void 0;
|
|
4
|
+
exports.providerFor = providerFor;
|
|
4
5
|
exports.resolveModel = resolveModel;
|
|
5
6
|
exports.parseCostOverride = parseCostOverride;
|
|
6
7
|
exports.priceFor = priceFor;
|
|
7
8
|
exports.estimateCostUsd = estimateCostUsd;
|
|
8
9
|
const errors_1 = require("../errors");
|
|
9
|
-
// Per-1M-token prices
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
'claude-
|
|
17
|
-
'claude-
|
|
10
|
+
// Per-1M-token prices + owning provider — the SINGLE source of truth. MODEL_PRICES,
|
|
11
|
+
// ALLOWED_MODELS and providerFor all derive from this, so the --model allowlist, its
|
|
12
|
+
// price and its backend can never disagree. Prices WILL drift between releases
|
|
13
|
+
// (Anthropic cached 2026-05-26; OpenAI 2026-07-02) — `--cost-override <input/output>`
|
|
14
|
+
// is the escape hatch and is authoritative when supplied. Every model here bills cached
|
|
15
|
+
// input at ~0.1x (Anthropic + OpenAI gpt-5.x alike), matching CACHE_READ_MULT below.
|
|
16
|
+
const MODELS = {
|
|
17
|
+
'claude-haiku-4-5': { input: 1, output: 5, provider: 'anthropic' },
|
|
18
|
+
'claude-sonnet-4-6': { input: 3, output: 15, provider: 'anthropic' },
|
|
19
|
+
'claude-opus-4-8': { input: 5, output: 25, provider: 'anthropic' },
|
|
20
|
+
'claude-fable-5': { input: 10, output: 50, provider: 'anthropic' },
|
|
21
|
+
'gpt-5.4-mini': { input: 0.75, output: 4.5, provider: 'openai' },
|
|
22
|
+
'gpt-5.4': { input: 2.5, output: 15, provider: 'openai' },
|
|
23
|
+
'gpt-5.5': { input: 5, output: 30, provider: 'openai' },
|
|
18
24
|
};
|
|
19
|
-
exports.
|
|
25
|
+
exports.MODEL_PRICES = Object.fromEntries(Object.entries(MODELS).map(([m, s]) => [m, { input: s.input, output: s.output }]));
|
|
26
|
+
exports.ALLOWED_MODELS = Object.keys(MODELS);
|
|
20
27
|
exports.DEFAULT_MODEL = 'claude-sonnet-4-6';
|
|
28
|
+
/** Resolve which provider backend serves a model (unknown → anthropic, the default). */
|
|
29
|
+
function providerFor(model) {
|
|
30
|
+
return MODELS[model]?.provider ?? 'anthropic';
|
|
31
|
+
}
|
|
21
32
|
/** Default total-run cost ceiling for `vk ai` when --max-cost-usd is not given, so a
|
|
22
33
|
* runaway compile/repair loop can't spend unbounded tokens. */
|
|
23
34
|
exports.DEFAULT_MAX_COST_USD = 3;
|
package/dist/agent/engine.js
CHANGED
|
@@ -8,6 +8,11 @@ const ir_1 = require("./ir");
|
|
|
8
8
|
const describe = (leaf) => [leaf.command, ...leaf.positionals, ...leaf.flags.map((f) => (f.value === 'true' ? `--${f.name}` : `--${f.name} ${f.value}`))]
|
|
9
9
|
.join(' ')
|
|
10
10
|
.trim();
|
|
11
|
+
/** A screenshot leaf is best-effort review evidence, not a gate. The `vk ai`
|
|
12
|
+
* grammar has the model sprinkle them around transitions, so a capture that
|
|
13
|
+
* fails (a device hiccup on screencap) must never turn a green run red — see
|
|
14
|
+
* the guard in execLeaf. */
|
|
15
|
+
const isScreenshotLeaf = (leaf) => leaf.command === 'screenshot' || leaf.command === 'shot';
|
|
11
16
|
/** A structural fingerprint of the screen: sorted id+text+type set. Used for the
|
|
12
17
|
* loop no-progress check — deliberately NOT the raw hierarchy (its node ordering
|
|
13
18
|
* is nondeterministic between identical states, which would false-trip). */
|
|
@@ -31,15 +36,15 @@ async function runPlan(plan, deps) {
|
|
|
31
36
|
// A UI dump can fail transiently on a real device (uiautomator throws). Treat
|
|
32
37
|
// that as "empty screen" rather than letting it abort the whole run — the rest
|
|
33
38
|
// of the codebase treats dumps as recoverable (resolveOneWaiting re-polls).
|
|
34
|
-
const safeElements = () => {
|
|
39
|
+
const safeElements = async () => {
|
|
35
40
|
try {
|
|
36
|
-
return deps.getElements();
|
|
41
|
+
return await deps.getElements();
|
|
37
42
|
}
|
|
38
43
|
catch {
|
|
39
44
|
return [];
|
|
40
45
|
}
|
|
41
46
|
};
|
|
42
|
-
const present = (selector) => {
|
|
47
|
+
const present = async (selector) => {
|
|
43
48
|
// Re-fetch on a dump FAILURE (uiautomator can throw transiently) so a flaky dump at
|
|
44
49
|
// a guard check doesn't silently read as "absent" and skip a body that should run.
|
|
45
50
|
// Once a dump SUCCEEDS (even if empty) we trust it — no slow re-poll, so a genuinely
|
|
@@ -47,7 +52,7 @@ async function runPlan(plan, deps) {
|
|
|
47
52
|
let els;
|
|
48
53
|
for (let i = 0; i < 2 && els === undefined; i++) {
|
|
49
54
|
try {
|
|
50
|
-
els = deps.getElements();
|
|
55
|
+
els = await deps.getElements();
|
|
51
56
|
}
|
|
52
57
|
catch {
|
|
53
58
|
/* transient dump failure — retry once before concluding "absent" */
|
|
@@ -92,7 +97,7 @@ async function runPlan(plan, deps) {
|
|
|
92
97
|
failedStep: current,
|
|
93
98
|
reason,
|
|
94
99
|
candidates,
|
|
95
|
-
hierarchy: safeElements(),
|
|
100
|
+
hierarchy: await safeElements(),
|
|
96
101
|
});
|
|
97
102
|
deps.cost.add(usage, 'repair');
|
|
98
103
|
// The model may DECLINE (null) when the current screen has no element serving
|
|
@@ -129,6 +134,19 @@ async function runPlan(plan, deps) {
|
|
|
129
134
|
}
|
|
130
135
|
if (outcome.code === 0)
|
|
131
136
|
return { status: 'ok' };
|
|
137
|
+
// A review screenshot is best-effort evidence, never a gate. The grammar has the
|
|
138
|
+
// model insert them liberally around transitions, so a capture that fails (a
|
|
139
|
+
// device hiccup on screencap) must not fail an otherwise-green run. Log it,
|
|
140
|
+
// downgrade the just-recorded failed step to a clean pass (markLastStepHealed
|
|
141
|
+
// sets status=passed/exitCode=0 and drops the failure evidence) so the report and
|
|
142
|
+
// JUnit stay consistent with the green run, then continue. Scoped to screenshot/
|
|
143
|
+
// shot — every other command's failure stays terminal.
|
|
144
|
+
if (isScreenshotLeaf(current)) {
|
|
145
|
+
const why = outcome.error ? outcome.error.message.split('\n')[0] : `exited ${outcome.code}`;
|
|
146
|
+
deps.log(`[ai] ${where}: screenshot capture failed (${why}) — continuing (best-effort review screenshot)`);
|
|
147
|
+
deps.markHealed?.(`screenshot capture failed (${why}) — skipped (best-effort)`);
|
|
148
|
+
return { status: 'ok' };
|
|
149
|
+
}
|
|
132
150
|
if (isHealable(outcome)) {
|
|
133
151
|
return { status: 'fail', where, reason: `unresolved after ${maxRepairs} repair attempt(s): ${outcome.error.message.split('\n')[0]}` };
|
|
134
152
|
}
|
|
@@ -149,7 +167,7 @@ async function runPlan(plan, deps) {
|
|
|
149
167
|
case 'command':
|
|
150
168
|
return execLeaf(node, where, replace);
|
|
151
169
|
case 'if-present': {
|
|
152
|
-
if (present(node.selector)) {
|
|
170
|
+
if (await present(node.selector)) {
|
|
153
171
|
deps.log(`[ai] ${where}: if-present '${node.selector}' → present, running ${node.body.length} step(s)`);
|
|
154
172
|
return walkBody(node.body, where);
|
|
155
173
|
}
|
|
@@ -163,11 +181,11 @@ async function runPlan(plan, deps) {
|
|
|
163
181
|
deps.log(`[ai] ${where}: run timeout reached — stopping repeat after ${i} iteration(s)`);
|
|
164
182
|
return { status: 'timeout' };
|
|
165
183
|
}
|
|
166
|
-
if (present(node.selector)) {
|
|
184
|
+
if (await present(node.selector)) {
|
|
167
185
|
deps.log(`[ai] ${where}: repeat reached '${node.selector}' after ${i} iteration(s)`);
|
|
168
186
|
return { status: 'ok' };
|
|
169
187
|
}
|
|
170
|
-
const hash = structuralHash(safeElements());
|
|
188
|
+
const hash = structuralHash(await safeElements());
|
|
171
189
|
if (i > 0 && hash === prevHash) {
|
|
172
190
|
deps.log(`[ai] ${where}: repeat made no progress (screen unchanged) — stopping after ${i} iteration(s)`);
|
|
173
191
|
return { status: 'ok' };
|
package/dist/agent/grammar.js
CHANGED
|
@@ -54,7 +54,12 @@ RULES:
|
|
|
54
54
|
- assert is for VERIFICATION only and is terminal — never use it as a step you expect to
|
|
55
55
|
fail. Put genuinely-optional UI behind if-present.
|
|
56
56
|
- Prefer resource-id / accessibility selectors over visible text where possible.
|
|
57
|
-
- Translate the test literally and minimally
|
|
57
|
+
- Translate the test literally and minimally: do not invent ACTION steps (tap/text/swipe/key/assert)
|
|
58
|
+
the prose does not imply. The ONE exception is screenshot — insert screenshot steps liberally as
|
|
59
|
+
post-run review evidence: after each screen transition (launch, a navigation tap, a submit, a
|
|
60
|
+
swipe/scroll) AND inside if-present/repeat bodies, so a failing branch or loop iteration is visible
|
|
61
|
+
in the report. Screenshots never affect the result; err toward too many. They are dumped into the
|
|
62
|
+
report for humans and never read back, so they are free on replay.`;
|
|
58
63
|
exports.REPAIR_GRAMMAR = `A single step in a verikun plan failed to resolve its selector against the live screen
|
|
59
64
|
(shown below). Decide between two outcomes — and be STRICT:
|
|
60
65
|
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OpenAiProvider = void 0;
|
|
4
|
+
exports.mapUsage = mapUsage;
|
|
5
|
+
exports.toStrictSchema = toStrictSchema;
|
|
6
|
+
const errors_1 = require("../errors");
|
|
7
|
+
const format_1 = require("../ui/format");
|
|
8
|
+
const ir_1 = require("./ir");
|
|
9
|
+
const grammar_1 = require("./grammar");
|
|
10
|
+
// The OpenAI provider: OpenAI's Chat Completions API over Node's built-in fetch — no
|
|
11
|
+
// SDK, honoring the repo's zero-runtime-dependency rule (a sibling to claude.ts). The
|
|
12
|
+
// LLM ecosystem converged on this /chat/completions shape, so this same class drives any
|
|
13
|
+
// OpenAI-compatible endpoint (Groq, xAI, Together, DeepSeek, Gemini-compat) by pointing
|
|
14
|
+
// `baseUrl` at it — that is the whole point of doing it here rather than per vendor.
|
|
15
|
+
//
|
|
16
|
+
// Structured output: response_format json_schema with strict:true, so generation is
|
|
17
|
+
// HARD-CONSTRAINED to the plan/repair schema. json_object mode is not enough — a weaker
|
|
18
|
+
// model (e.g. gpt-5.4-mini) emits flags as a map instead of the {name,value}[] the IR
|
|
19
|
+
// requires, which parsePlan then rejects. The shared ir.ts schemas are adapted to
|
|
20
|
+
// OpenAI's strict dialect at call time by toStrictSchema (no duplication, no ir.ts
|
|
21
|
+
// change); engine.ts's parsePlan/validateNode stays the execution trust boundary
|
|
22
|
+
// regardless. The model runs here ONLY on compile + repair, never on replay.
|
|
23
|
+
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
24
|
+
// Per-request wall-clock cap so a stalled connection can't hang the run past its
|
|
25
|
+
// --timeout deadline (the engine checks the deadline only between calls, not during one).
|
|
26
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
|
27
|
+
// gpt-5.x are reasoning models: they take reasoning_effort, require max_completion_tokens
|
|
28
|
+
// (not max_tokens), and reject a custom temperature. Map verikun's effort scale onto
|
|
29
|
+
// OpenAI's (whose ceiling is 'high'), and only send it when the caller asked for one.
|
|
30
|
+
// INVARIANT: every OpenAI model in cost.ts's registry is a reasoning model that accepts
|
|
31
|
+
// reasoning_effort. If a non-reasoning model is ever added there, gate this send behind an
|
|
32
|
+
// allowlist like claude.ts's EFFORT_MODELS (else it 400s → exit 2 with no retry).
|
|
33
|
+
const EFFORT_MAP = {
|
|
34
|
+
low: 'low',
|
|
35
|
+
medium: 'medium',
|
|
36
|
+
high: 'high',
|
|
37
|
+
xhigh: 'high',
|
|
38
|
+
max: 'high',
|
|
39
|
+
};
|
|
40
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
41
|
+
const backoffMs = (attempt) => Math.min(1000 * 2 ** (attempt - 1), 15000);
|
|
42
|
+
/** Map OpenAI's usage onto the normalized (Anthropic-shaped) Usage the CostTracker
|
|
43
|
+
* prices. OpenAI's prompt_tokens INCLUDES the cached tokens, so subtract them out:
|
|
44
|
+
* uncached prompt → input_tokens (full price), cached → cache_read_input_tokens (0.1x).
|
|
45
|
+
* OpenAI has no separate cache-write charge, so cache_creation is 0. completion_tokens
|
|
46
|
+
* already includes reasoning tokens on gpt-5.x, so output is priced correctly. A response
|
|
47
|
+
* with no usage block maps to all-zeros (cost under-counts) — acceptable because the
|
|
48
|
+
* repair loop is also bounded by maxRepairs and the --timeout deadline, not cost alone. */
|
|
49
|
+
function mapUsage(u) {
|
|
50
|
+
const cached = u?.prompt_tokens_details?.cached_tokens ?? 0;
|
|
51
|
+
const prompt = u?.prompt_tokens ?? 0;
|
|
52
|
+
return {
|
|
53
|
+
input_tokens: Math.max(0, prompt - cached),
|
|
54
|
+
output_tokens: u?.completion_tokens ?? 0,
|
|
55
|
+
cache_read_input_tokens: cached,
|
|
56
|
+
cache_creation_input_tokens: 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Adapt a JSON Schema to OpenAI's strict Structured-Outputs dialect: every object must
|
|
60
|
+
* set additionalProperties:false and list ALL its properties in `required`, so a
|
|
61
|
+
* previously-optional field is kept but made nullable. Non-mutating — lets the shared
|
|
62
|
+
* ir.ts schemas stay the single source of truth while OpenAI hard-constrains generation. */
|
|
63
|
+
function toStrictSchema(schema) {
|
|
64
|
+
if (Array.isArray(schema))
|
|
65
|
+
return schema.map(toStrictSchema);
|
|
66
|
+
if (!schema || typeof schema !== 'object')
|
|
67
|
+
return schema;
|
|
68
|
+
const src = schema;
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const [k, v] of Object.entries(src))
|
|
71
|
+
out[k] = toStrictSchema(v);
|
|
72
|
+
const props = out.properties;
|
|
73
|
+
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
|
74
|
+
const p = props;
|
|
75
|
+
const keys = Object.keys(p);
|
|
76
|
+
const wasRequired = new Set(Array.isArray(out.required) ? out.required : []);
|
|
77
|
+
out.additionalProperties = false;
|
|
78
|
+
out.required = keys;
|
|
79
|
+
for (const k of keys)
|
|
80
|
+
if (!wasRequired.has(k))
|
|
81
|
+
p[k] = makeNullable(p[k]);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/** Widen a property schema to also admit null (how strict mode expresses "optional"). */
|
|
86
|
+
function makeNullable(prop) {
|
|
87
|
+
if (!prop || typeof prop !== 'object')
|
|
88
|
+
return prop;
|
|
89
|
+
const p = prop;
|
|
90
|
+
if (Array.isArray(p.anyOf))
|
|
91
|
+
return { ...p, anyOf: [...p.anyOf, { type: 'null' }] };
|
|
92
|
+
if (typeof p.type === 'string') {
|
|
93
|
+
const next = { ...p, type: [p.type, 'null'] };
|
|
94
|
+
if (Array.isArray(p.enum))
|
|
95
|
+
next.enum = [...p.enum, null];
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(p.type)) {
|
|
99
|
+
return { ...p, type: [...p.type.filter((t) => t !== 'null'), 'null'] };
|
|
100
|
+
}
|
|
101
|
+
return { anyOf: [prop, { type: 'null' }] };
|
|
102
|
+
}
|
|
103
|
+
class OpenAiProvider {
|
|
104
|
+
opts;
|
|
105
|
+
constructor(opts) {
|
|
106
|
+
this.opts = opts;
|
|
107
|
+
}
|
|
108
|
+
async compile(input) {
|
|
109
|
+
const parts = [];
|
|
110
|
+
if (input.pkg)
|
|
111
|
+
parts.push(`App package: ${input.pkg}`);
|
|
112
|
+
parts.push(`Platform: ${input.platform}`);
|
|
113
|
+
if (input.seed) {
|
|
114
|
+
parts.push('A plan compiled for a PREVIOUS build of this app follows. Reuse it where the test still holds; ' +
|
|
115
|
+
'change only what the test now requires. PRIOR PLAN:\n' +
|
|
116
|
+
JSON.stringify(input.seed, null, 2));
|
|
117
|
+
}
|
|
118
|
+
parts.push('NATURAL-LANGUAGE TEST:\n' + input.nl);
|
|
119
|
+
// Generous completion budget: on reasoning models the plan JSON shares this ceiling
|
|
120
|
+
// with reasoning tokens, so leave headroom (a 'length' finish is surfaced as an error).
|
|
121
|
+
const { json, usage } = await this.call(grammar_1.GRAMMAR, parts.join('\n\n'), ir_1.PLAN_JSON_SCHEMA, 16384);
|
|
122
|
+
return { plan: (0, ir_1.parsePlan)(json), usage };
|
|
123
|
+
}
|
|
124
|
+
async repair(ctx) {
|
|
125
|
+
const parts = ['FAILED STEP: ' + JSON.stringify(ctx.failedStep), 'FAILURE: ' + ctx.reason];
|
|
126
|
+
if (ctx.candidates && ctx.candidates.length) {
|
|
127
|
+
parts.push(`The selector matched ${ctx.candidates.length} elements (ambiguous) — pick a more specific selector for the SAME intended element, or give_up if none of them is it.`);
|
|
128
|
+
}
|
|
129
|
+
parts.push('CURRENT SCREEN:\n' + (0, format_1.formatCompact)(ctx.hierarchy));
|
|
130
|
+
const { json, usage } = await this.call(grammar_1.REPAIR_GRAMMAR, parts.join('\n\n'), ir_1.REPAIR_DECISION_JSON_SCHEMA, 4096);
|
|
131
|
+
const decision = (json ?? {});
|
|
132
|
+
if (decision.decision === 'give_up') {
|
|
133
|
+
return {
|
|
134
|
+
replaceStep: null,
|
|
135
|
+
declineReason: decision.reason?.trim() || 'no element on the current screen matches the step intent',
|
|
136
|
+
usage,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// Hand the proposed leaf back UNVALIDATED — engine.ts validates every repair against
|
|
140
|
+
// the grammar before splicing (it is the execution trust boundary and can't assume a
|
|
141
|
+
// provider validated). A missing/invalid step is rejected there as a failed repair.
|
|
142
|
+
return { replaceStep: (decision.step ?? null), usage };
|
|
143
|
+
}
|
|
144
|
+
async call(system, user, schema, maxTokens) {
|
|
145
|
+
const body = {
|
|
146
|
+
model: this.opts.model,
|
|
147
|
+
max_completion_tokens: maxTokens,
|
|
148
|
+
// Hard-constrain generation to the schema (strict Structured Outputs), adapting the
|
|
149
|
+
// shared ir.ts schema to OpenAI's strict dialect. parsePlan/validateNode still gates.
|
|
150
|
+
response_format: {
|
|
151
|
+
type: 'json_schema',
|
|
152
|
+
json_schema: { name: 'verikun_output', strict: true, schema: toStrictSchema(schema) },
|
|
153
|
+
},
|
|
154
|
+
messages: [
|
|
155
|
+
{ role: 'system', content: system },
|
|
156
|
+
{ role: 'user', content: user },
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
if (this.opts.effort) {
|
|
160
|
+
const mapped = EFFORT_MAP[this.opts.effort];
|
|
161
|
+
if (mapped)
|
|
162
|
+
body.reasoning_effort = mapped;
|
|
163
|
+
}
|
|
164
|
+
const res = await this.fetchWithRetry(body);
|
|
165
|
+
const choice = res.choices?.[0];
|
|
166
|
+
if (choice?.message?.refusal) {
|
|
167
|
+
throw new errors_1.CliError(`Model refused the request: ${choice.message.refusal}`, 1);
|
|
168
|
+
}
|
|
169
|
+
if (choice?.finish_reason === 'length') {
|
|
170
|
+
throw new errors_1.CliError('Model output was truncated (finish_reason=length) before a complete result — raise the budget or shorten the test.', 1);
|
|
171
|
+
}
|
|
172
|
+
if (choice?.finish_reason === 'content_filter') {
|
|
173
|
+
throw new errors_1.CliError('Model output was blocked by the content filter.', 1);
|
|
174
|
+
}
|
|
175
|
+
const text = (choice?.message?.content ?? '').trim();
|
|
176
|
+
if (!text)
|
|
177
|
+
throw new errors_1.CliError('Model returned an empty response.', 1);
|
|
178
|
+
let json;
|
|
179
|
+
try {
|
|
180
|
+
json = JSON.parse(text);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new errors_1.CliError('Model output was not valid JSON.', 1);
|
|
184
|
+
}
|
|
185
|
+
return { json, usage: mapUsage(res.usage) };
|
|
186
|
+
}
|
|
187
|
+
async fetchWithRetry(body) {
|
|
188
|
+
const maxRetries = this.opts.maxRetries ?? 4;
|
|
189
|
+
const timeoutMs = this.opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
190
|
+
const doFetch = this.opts.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
191
|
+
const doSleep = this.opts.sleepImpl ?? sleep;
|
|
192
|
+
const url = (this.opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '') + '/chat/completions';
|
|
193
|
+
let attempt = 0;
|
|
194
|
+
for (;;) {
|
|
195
|
+
// Abort a stalled request after timeoutMs so it can't hang forever; the abort is
|
|
196
|
+
// caught below and retried like any other network error (bounded by maxRetries).
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
199
|
+
let res;
|
|
200
|
+
try {
|
|
201
|
+
res = await doFetch(url, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: {
|
|
204
|
+
'content-type': 'application/json',
|
|
205
|
+
authorization: `Bearer ${this.opts.apiKey}`,
|
|
206
|
+
},
|
|
207
|
+
body: JSON.stringify(body),
|
|
208
|
+
signal: controller.signal,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
if (attempt++ >= maxRetries)
|
|
213
|
+
throw new errors_1.CliError(`OpenAI API request failed: ${e.message}`, 3);
|
|
214
|
+
await doSleep(backoffMs(attempt));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
}
|
|
220
|
+
if (res.ok) {
|
|
221
|
+
try {
|
|
222
|
+
return (await res.json());
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// A 2xx with a non-JSON body (proxy/API corruption) — map to the env exit code
|
|
226
|
+
// rather than letting a raw SyntaxError escape as an unhandled throw.
|
|
227
|
+
throw new errors_1.CliError('OpenAI returned a non-JSON success body.', 3);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Retry 429 + 5xx with backoff, honoring Retry-After (no SDK to do it for us).
|
|
231
|
+
if ((res.status === 429 || res.status >= 500) && attempt++ < maxRetries) {
|
|
232
|
+
const retryAfter = Number(res.headers.get('retry-after'));
|
|
233
|
+
await doSleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const errText = await res.text().catch(() => '');
|
|
237
|
+
// 401/403 = auth/permission (env); 400 = bad request (usage); else env.
|
|
238
|
+
const code = res.status === 401 || res.status === 403 ? 3 : res.status === 400 ? 2 : 3;
|
|
239
|
+
throw new errors_1.CliError(`OpenAI API error ${res.status}: ${errText.slice(0, 500)}`, code);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
exports.OpenAiProvider = OpenAiProvider;
|