explorbot 0.5.1 → 0.5.3
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/bin/explorbot-cli.ts +3 -0
- package/dist/bin/explorbot-cli.js +2 -0
- package/dist/package.json +1 -1
- package/dist/src/action.d.ts +1 -0
- package/dist/src/action.js +9 -0
- package/dist/src/ai/historian/codeceptjs.js +2 -0
- package/dist/src/ai/judge-provider.js +5 -0
- package/dist/src/ai/judge-tool.js +5 -1
- package/dist/src/ai/judge.d.ts +3 -1
- package/dist/src/ai/judge.js +29 -6
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +20 -4
- package/dist/src/ai/provider.js +14 -7
- package/dist/src/ai/rerunner.js +3 -1
- package/dist/src/ai/rules.js +1 -1
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +22 -4
- package/dist/src/ai/tools.js +34 -14
- package/dist/src/config.d.ts +11 -3
- package/dist/src/config.js +42 -6
- package/dist/src/explorbot.js +4 -0
- package/dist/src/explorer.d.ts +2 -0
- package/dist/src/explorer.js +33 -1
- package/dist/src/global-config.js +1 -1
- package/dist/src/utils/test-files.d.ts +5 -0
- package/dist/src/utils/test-files.js +28 -1
- package/docs/basics/providers.md +3 -2
- package/docs/reference/commands.md +12 -0
- package/docs/web-testing/decisions.md +18 -7
- package/package.json +1 -1
- package/src/action.ts +10 -0
- package/src/ai/historian/codeceptjs.ts +2 -0
- package/src/ai/judge-provider.ts +6 -0
- package/src/ai/judge-tool.ts +5 -1
- package/src/ai/judge.ts +25 -6
- package/src/ai/pilot.ts +20 -4
- package/src/ai/provider.ts +13 -7
- package/src/ai/rerunner.ts +3 -1
- package/src/ai/rules.ts +1 -1
- package/src/ai/tester.ts +23 -3
- package/src/ai/tools.ts +37 -21
- package/src/config.ts +49 -7
- package/src/explorbot.ts +2 -0
- package/src/explorer.ts +37 -2
- package/src/global-config.ts +1 -1
- package/src/utils/test-files.ts +27 -1
package/dist/src/config.js
CHANGED
|
@@ -65,6 +65,7 @@ export class ConfigParser {
|
|
|
65
65
|
site = null;
|
|
66
66
|
siteStartPath = '/';
|
|
67
67
|
siteConfigPath = null;
|
|
68
|
+
runBase = null;
|
|
68
69
|
constructor() { }
|
|
69
70
|
static loadEnv(filePath, keepExisting = false) {
|
|
70
71
|
const resolved = resolve(filePath);
|
|
@@ -96,6 +97,7 @@ export class ConfigParser {
|
|
|
96
97
|
if (this.config && !options?.config && !options?.path && this.runtimeTarget === target) {
|
|
97
98
|
return this.config;
|
|
98
99
|
}
|
|
100
|
+
this.runBase = parseRunBase(options?.baseUrl);
|
|
99
101
|
// Store the initial working directory for reference
|
|
100
102
|
if (!process.env.INITIAL_CWD) {
|
|
101
103
|
process.env.INITIAL_CWD = process.cwd();
|
|
@@ -123,12 +125,19 @@ export class ConfigParser {
|
|
|
123
125
|
let envUrl = options?.baseUrl;
|
|
124
126
|
if (!envUrl && target?.startsWith('http'))
|
|
125
127
|
envUrl = target;
|
|
126
|
-
|
|
128
|
+
if (options?.baseUrl) {
|
|
129
|
+
const envOrigin = URL.parse(process.env.EXPLORBOT_URL || '')?.origin;
|
|
130
|
+
const baseOrigin = URL.parse(options.baseUrl)?.origin;
|
|
131
|
+
if (envOrigin && envOrigin !== baseOrigin) {
|
|
132
|
+
tag('warning').log(`--base-url ${baseOrigin} selects the site for this run — EXPLORBOT_URL ${envOrigin} is ignored`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const outputRoot = resolveOutputRoot(options?.baseUrl || process.env.EXPLORBOT_URL || envUrl);
|
|
127
136
|
loadedConfig = await this.buildEnvConfig(envUrl, outputRoot);
|
|
128
137
|
sourcePath = join(outputRoot, 'explorbot.config.js');
|
|
129
138
|
log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
|
|
130
139
|
}
|
|
131
|
-
let config = this.resolveConfig(loadedConfig
|
|
140
|
+
let config = this.resolveConfig(loadedConfig);
|
|
132
141
|
await resolveConfigModels(config.ai);
|
|
133
142
|
this.site = null;
|
|
134
143
|
this.siteConfigPath = null;
|
|
@@ -195,6 +204,22 @@ export class ConfigParser {
|
|
|
195
204
|
getSiteConfigPath() {
|
|
196
205
|
return this.siteConfigPath;
|
|
197
206
|
}
|
|
207
|
+
applyBasePath(target) {
|
|
208
|
+
const base = this.runBase?.path;
|
|
209
|
+
if (!base)
|
|
210
|
+
return target;
|
|
211
|
+
if (!target.startsWith('/'))
|
|
212
|
+
return target;
|
|
213
|
+
if (target === base || target.startsWith(`${base}/`) || target.startsWith(`${base}?`))
|
|
214
|
+
return target;
|
|
215
|
+
return `${base}${target}`;
|
|
216
|
+
}
|
|
217
|
+
getBaseQuery() {
|
|
218
|
+
return this.runBase?.query || '';
|
|
219
|
+
}
|
|
220
|
+
getBasePath() {
|
|
221
|
+
return this.runBase?.path || '';
|
|
222
|
+
}
|
|
198
223
|
resolveTargetPath(target) {
|
|
199
224
|
if (!this.site) {
|
|
200
225
|
const configured = this.config?.playwright?.url || this.config?.web?.url;
|
|
@@ -203,13 +228,15 @@ export class ConfigParser {
|
|
|
203
228
|
if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
|
|
204
229
|
tag('warning').log(`Exploring ${targetOrigin} but base URL is ${baseOrigin}. Relative navigation resolves against the base URL — set web.url to ${targetOrigin} to avoid it.`);
|
|
205
230
|
}
|
|
206
|
-
return target || '/';
|
|
231
|
+
return this.applyBasePath(target || '/');
|
|
207
232
|
}
|
|
208
233
|
if (!target)
|
|
209
234
|
return this.siteStartPath;
|
|
210
235
|
const resolved = resolveSiteTarget(target, this.site.url);
|
|
211
236
|
if (resolved.baseUrl !== this.site.url)
|
|
212
237
|
return target;
|
|
238
|
+
if (target.startsWith('/'))
|
|
239
|
+
return this.applyBasePath(resolved.path);
|
|
213
240
|
return resolved.path;
|
|
214
241
|
}
|
|
215
242
|
getStatesDir() {
|
|
@@ -232,6 +259,7 @@ export class ConfigParser {
|
|
|
232
259
|
ConfigParser.instance.site = null;
|
|
233
260
|
ConfigParser.instance.siteStartPath = '/';
|
|
234
261
|
ConfigParser.instance.siteConfigPath = null;
|
|
262
|
+
ConfigParser.instance.runBase = null;
|
|
235
263
|
}
|
|
236
264
|
}
|
|
237
265
|
// For testing purposes only - sets up minimal default config
|
|
@@ -393,14 +421,14 @@ export class ConfigParser {
|
|
|
393
421
|
return JSON.parse(content);
|
|
394
422
|
}
|
|
395
423
|
}
|
|
396
|
-
resolveConfig(config
|
|
424
|
+
resolveConfig(config) {
|
|
397
425
|
if (config.web?.url && !config.playwright?.url) {
|
|
398
426
|
config.playwright = config.playwright || { browser: 'chromium', url: '' };
|
|
399
427
|
config.playwright.url = config.web.url;
|
|
400
428
|
}
|
|
401
|
-
if (
|
|
429
|
+
if (this.runBase) {
|
|
402
430
|
config.playwright = config.playwright || { browser: 'chromium', url: '' };
|
|
403
|
-
config.playwright.url =
|
|
431
|
+
config.playwright.url = this.runBase.origin;
|
|
404
432
|
}
|
|
405
433
|
resolveLangfuse(config.ai);
|
|
406
434
|
return config;
|
|
@@ -609,3 +637,11 @@ export async function createModel(provider, modelId) {
|
|
|
609
637
|
}
|
|
610
638
|
return (await info.load())(modelId);
|
|
611
639
|
}
|
|
640
|
+
function parseRunBase(baseUrl) {
|
|
641
|
+
if (!baseUrl)
|
|
642
|
+
return null;
|
|
643
|
+
const url = URL.parse(baseUrl);
|
|
644
|
+
if (!url)
|
|
645
|
+
throw new Error(`Base URL must be a full URL like https://app.example.com/team/acme, got "${baseUrl}"`);
|
|
646
|
+
return { origin: url.origin, path: url.pathname.replace(/\/+$/, ''), query: url.search };
|
|
647
|
+
}
|
package/dist/src/explorbot.js
CHANGED
|
@@ -449,6 +449,10 @@ export class ExplorBot {
|
|
|
449
449
|
this.currentPlan = plan;
|
|
450
450
|
if (!plan)
|
|
451
451
|
return;
|
|
452
|
+
if (plan.url)
|
|
453
|
+
plan.url = this.configParser.applyBasePath(plan.url);
|
|
454
|
+
for (const test of plan.tests)
|
|
455
|
+
test.startUrl = this.configParser.applyBasePath(test.startUrl);
|
|
452
456
|
if (!this.sessionPlans.includes(plan)) {
|
|
453
457
|
this.sessionPlans.push(plan);
|
|
454
458
|
}
|
package/dist/src/explorer.d.ts
CHANGED
|
@@ -57,6 +57,8 @@ declare class Explorer {
|
|
|
57
57
|
convertToCodeceptConfig(config: ExplorbotConfig): any;
|
|
58
58
|
connectOrLaunchBrowser(): Promise<void>;
|
|
59
59
|
openContextPage(): Promise<void>;
|
|
60
|
+
applyBasePathToHelper(): void;
|
|
61
|
+
pinBaseQuery(): Promise<void>;
|
|
60
62
|
createBrowserContextOptions(): BrowserContextOptions;
|
|
61
63
|
attachXhrCapture(): void;
|
|
62
64
|
runWithRecovery<T>(label: string, operation: () => Promise<T>): Promise<T>;
|
package/dist/src/explorer.js
CHANGED
|
@@ -73,6 +73,7 @@ class Explorer {
|
|
|
73
73
|
if (!this.playwrightHelper) {
|
|
74
74
|
throw new Error('Playwright helper not available');
|
|
75
75
|
}
|
|
76
|
+
this.applyBasePathToHelper();
|
|
76
77
|
await this.connectOrLaunchBrowser();
|
|
77
78
|
const hasSession = !this.options?.attachedBrowser && this.options?.session && existsSync(this.options.session);
|
|
78
79
|
await this.openContextPage();
|
|
@@ -124,7 +125,7 @@ class Explorer {
|
|
|
124
125
|
}
|
|
125
126
|
async visit(url, opts = {}) {
|
|
126
127
|
return this.runWithRecovery('visit', async () => {
|
|
127
|
-
const action = await this.visitOnce(url);
|
|
128
|
+
const action = await this.visitOnce(ConfigParser.getInstance().applyBasePath(url));
|
|
128
129
|
if (opts.screenshot)
|
|
129
130
|
return action.capturePageState({ includeScreenshot: true });
|
|
130
131
|
return action.getActionResult() ?? action.capturePageState();
|
|
@@ -279,6 +280,7 @@ class Explorer {
|
|
|
279
280
|
const attached = this.options?.attachedBrowser;
|
|
280
281
|
if (!attached) {
|
|
281
282
|
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
283
|
+
await this.pinBaseQuery();
|
|
282
284
|
return;
|
|
283
285
|
}
|
|
284
286
|
const context = attached.contexts()[0] || (await attached.newContext());
|
|
@@ -287,6 +289,36 @@ class Explorer {
|
|
|
287
289
|
this.playwrightHelper.browserContext = context;
|
|
288
290
|
await this.playwrightHelper._setPage(page);
|
|
289
291
|
debugLog(`Adopted attached browser page: ${page.url()}`);
|
|
292
|
+
await this.pinBaseQuery();
|
|
293
|
+
}
|
|
294
|
+
applyBasePathToHelper() {
|
|
295
|
+
if (!ConfigParser.getInstance().getBasePath())
|
|
296
|
+
return;
|
|
297
|
+
const helper = this.playwrightHelper;
|
|
298
|
+
const original = helper.amOnPage.bind(helper);
|
|
299
|
+
helper.amOnPage = (url) => original(ConfigParser.getInstance().applyBasePath(url));
|
|
300
|
+
}
|
|
301
|
+
async pinBaseQuery() {
|
|
302
|
+
const query = ConfigParser.getInstance().getBaseQuery();
|
|
303
|
+
if (!query)
|
|
304
|
+
return;
|
|
305
|
+
const pinned = new URLSearchParams(query);
|
|
306
|
+
const origin = URL.parse(this.config.playwright.url)?.origin;
|
|
307
|
+
await this.playwrightHelper.browserContext.route('**/*', async (route) => {
|
|
308
|
+
const request = route.request();
|
|
309
|
+
if (request.resourceType() !== 'document')
|
|
310
|
+
return route.continue();
|
|
311
|
+
const url = URL.parse(request.url());
|
|
312
|
+
if (!url || url.origin !== origin)
|
|
313
|
+
return route.continue();
|
|
314
|
+
const missing = [...pinned].filter(([key]) => !url.searchParams.has(key));
|
|
315
|
+
if (!missing.length)
|
|
316
|
+
return route.continue();
|
|
317
|
+
for (const [key, value] of missing)
|
|
318
|
+
url.searchParams.set(key, value);
|
|
319
|
+
return route.continue({ url: url.toString() });
|
|
320
|
+
});
|
|
321
|
+
tag('info').log(`Every page load of ${origin} carries ${query}`);
|
|
290
322
|
}
|
|
291
323
|
createBrowserContextOptions() {
|
|
292
324
|
const helperOptions = this.playwrightHelper.options || {};
|
|
@@ -116,7 +116,7 @@ export function resolveSiteTarget(target, defaultBaseUrl) {
|
|
|
116
116
|
return { baseUrl: url.origin, path: `${url.pathname}${url.search}${url.hash}` };
|
|
117
117
|
}
|
|
118
118
|
if (raw.startsWith('/')) {
|
|
119
|
-
const base =
|
|
119
|
+
const base = process.env.EXPLORBOT_URL || defaultBaseUrl;
|
|
120
120
|
if (!base) {
|
|
121
121
|
throw new Error(withSites(`Cannot resolve path "${raw}" without a site.`));
|
|
122
122
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export declare function listTestFiles(testsDir: string): string[];
|
|
2
|
+
/**
|
|
3
|
+
* Generated tests import `codeceptjs/steps`, but in global mode they lie in ~/.explorbot,
|
|
4
|
+
* far from any node_modules. Link the bot's own codeceptjs next to them so Node resolves it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function ensureCodeceptResolvable(dir: string): void;
|
|
2
7
|
export declare function loadTestSuites(testsDir: string): any[];
|
|
3
8
|
export declare function printTestList(suites: any[]): void;
|
|
4
9
|
export declare function dryRunTestFile(filePath: string): Promise<void>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, symlinkSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import chalk from 'chalk';
|
|
4
5
|
import { highlight } from 'cli-highlight';
|
|
@@ -8,6 +9,7 @@ import storeListener from 'codeceptjs/lib/listener/store';
|
|
|
8
9
|
import store from 'codeceptjs/lib/store';
|
|
9
10
|
import figureSet from 'figures';
|
|
10
11
|
import { ConfigParser } from "../config.js";
|
|
12
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
11
13
|
export function listTestFiles(testsDir) {
|
|
12
14
|
if (!existsSync(testsDir))
|
|
13
15
|
return [];
|
|
@@ -15,10 +17,34 @@ export function listTestFiles(testsDir) {
|
|
|
15
17
|
.filter((f) => f.endsWith('.js'))
|
|
16
18
|
.map((f) => path.resolve(testsDir, f));
|
|
17
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Generated tests import `codeceptjs/steps`, but in global mode they lie in ~/.explorbot,
|
|
22
|
+
* far from any node_modules. Link the bot's own codeceptjs next to them so Node resolves it.
|
|
23
|
+
*/
|
|
24
|
+
export function ensureCodeceptResolvable(dir) {
|
|
25
|
+
const link = path.join(dir, 'node_modules', 'codeceptjs');
|
|
26
|
+
if (existsSync(link))
|
|
27
|
+
return;
|
|
28
|
+
let target;
|
|
29
|
+
try {
|
|
30
|
+
target = path.dirname(nodeRequire.resolve('codeceptjs/package.json'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(path.dirname(link), { recursive: true });
|
|
37
|
+
symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Best effort: a project install already resolves codeceptjs without the link.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
18
43
|
export function loadTestSuites(testsDir) {
|
|
19
44
|
const jsFiles = listTestFiles(testsDir);
|
|
20
45
|
if (jsFiles.length === 0)
|
|
21
46
|
return [];
|
|
47
|
+
ensureCodeceptResolvable(testsDir);
|
|
22
48
|
codeceptjs.container.createMocha();
|
|
23
49
|
const mocha = codeceptjs.container.mocha();
|
|
24
50
|
mocha.files = jsFiles;
|
|
@@ -79,6 +105,7 @@ export async function dryRunTestFile(filePath) {
|
|
|
79
105
|
const mocha = codeceptjs.container.mocha();
|
|
80
106
|
mocha.reporter(class {
|
|
81
107
|
});
|
|
108
|
+
ensureCodeceptResolvable(path.dirname(absPath));
|
|
82
109
|
mocha.files = [absPath];
|
|
83
110
|
mocha.loadFiles();
|
|
84
111
|
let currentSuite = '';
|
package/docs/basics/providers.md
CHANGED
|
@@ -332,6 +332,7 @@ export default {
|
|
|
332
332
|
| `model` | `string` | Model ID at that provider |
|
|
333
333
|
| `tool` | `boolean` | Give Tester and Pilot a `judge` tool to ask their own questions. Default: `true` |
|
|
334
334
|
| `direct` | `boolean` | Consult the model at Explorbot's built-in decision points. Default: `true` |
|
|
335
|
+
| `threshold` | `number` | How sure the model must be before Explorbot acts on an answer, between 0 and 1. Default: `0.7` |
|
|
335
336
|
|
|
336
337
|
Without a config file, or to turn it on for one run, set `EXPLORBOT_DECISION_MODEL` or pass `--decision-model` as `provider/model-id`. It overrides `provider` and `model` in the config and keeps `tool` and `direct`:
|
|
337
338
|
|
|
@@ -340,9 +341,9 @@ EXPLORBOT_DECISION_MODEL=openrouter/typesafe/jev-1.13 npx explorbot explore
|
|
|
340
341
|
npx prima-cli go "Settings" --decision-model typesafe/jev-latest
|
|
341
342
|
```
|
|
342
343
|
|
|
343
|
-
Leave `decisionModel` unset and Explorbot never calls it. An unknown provider
|
|
344
|
+
Leave `decisionModel` unset and Explorbot never calls it. An unknown provider, a missing API key, or a `threshold` outside 0 and 1 stops Explorbot at startup.
|
|
344
345
|
|
|
345
|
-
Explorbot acts on an answer only when it is more than 70%
|
|
346
|
+
Explorbot acts on an answer only when it is more sure than `threshold` (70% by default), and otherwise does what it would have done without the decision model. See [Decisions](../web-testing/decisions.md) for where it is consulted, the `judge` tool, and what happens when an answer is rejected.
|
|
346
347
|
|
|
347
348
|
## Multi-Provider Configuration
|
|
348
349
|
|
|
@@ -66,11 +66,23 @@ Every CLI command that drives a browser accepts these options (`start`, `explore
|
|
|
66
66
|
| `--debug` | Enable debug logging (same as `--verbose`) |
|
|
67
67
|
| `-c, --config <path>` | Path to configuration file |
|
|
68
68
|
| `-p, --path <path>` | Working directory path |
|
|
69
|
+
| `--base-url <url>` | Run against this URL: its path scopes relative paths, its query params ride along with every page load |
|
|
69
70
|
| `-s, --show` | Show browser window |
|
|
70
71
|
| `--headless` | Run browser in headless mode |
|
|
71
72
|
| `--incognito` | Run without recording experiences |
|
|
72
73
|
| `--session [file]` | Save/restore browser session (cookies, localStorage) from file |
|
|
73
74
|
|
|
75
|
+
### `--base-url`
|
|
76
|
+
|
|
77
|
+
Points one run at one area of a site, leaving every config file alone. The origin selects the site, so the knowledge and experience already stored for that host still apply. The path prefixes every relative path a plan or a command gives, and the query params are added to each page load.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx explorbot test tests/plans/runs.md '*' \
|
|
81
|
+
--base-url 'https://app.example.com/teams/acme/?preview=pr-42'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A plan whose tests start at `/runs` runs them at `/teams/acme/runs`, and every page load carries `?preview=pr-42` — enough to keep a preview deployment selected for a whole session. The param travels on the request rather than in the address bar, so Explorbot navigates to clean URLs, though an app that redirects can still echo the param back. Absolute URLs are never prefixed: `https://app.example.com/users/sign_in` still points outside the scoped area, as a login page usually must.
|
|
85
|
+
|
|
74
86
|
### `--knowledge`
|
|
75
87
|
|
|
76
88
|
Passes facts to the run without creating a file in `knowledge/`. Plain text applies everywhere; add frontmatter to scope it to a page or an API endpoint. Repeat the flag for several facts. See [Knowledge](../workflow/knowledge.md#per-session-knowledge).
|
|
@@ -13,16 +13,18 @@ Every question has one of two shapes:
|
|
|
13
13
|
| **Statement** | "The page shows that this claim is true." | Probability that the statement holds |
|
|
14
14
|
| **Choice** | "Which listed element does the intent name?" plus numbered options | The chosen option and its probability |
|
|
15
15
|
|
|
16
|
-
A choice always includes an extra *undecided* option, so the model can say that none of the options fits.
|
|
16
|
+
A choice always includes an extra *undecided* option, so the model can say that none of the options fits. The question tells the model to choose it when it is not sure.
|
|
17
17
|
|
|
18
18
|
Explorbot acts on an answer only when it is **approved**:
|
|
19
19
|
|
|
20
|
-
- a statement's probability is above
|
|
21
|
-
- a choice picked a real option, not *undecided*, with a probability above
|
|
20
|
+
- a statement's probability is above the threshold, or
|
|
21
|
+
- a choice picked a real option, not *undecided*, with a probability above the threshold.
|
|
22
|
+
|
|
23
|
+
The threshold is **70%** unless `decisionModel.threshold` sets another value.
|
|
22
24
|
|
|
23
25
|
Anything else counts as **rejected**, including:
|
|
24
26
|
|
|
25
|
-
- a confident "no", or any probability
|
|
27
|
+
- a confident "no", or any probability at or below the threshold
|
|
26
28
|
- *undecided*
|
|
27
29
|
- a choice offered with fewer than two options
|
|
28
30
|
- a request timeout (15 seconds) or a failed request
|
|
@@ -38,12 +40,15 @@ These are fixed places in the code where Explorbot asks the decision model befor
|
|
|
38
40
|
|
|
39
41
|
When a click or fill fails because the locator matched more than one element, Explorbot lists the matches and asks which one the step meant.
|
|
40
42
|
|
|
41
|
-
- **Approved:**
|
|
43
|
+
- **Approved:** a click is repeated right away on the chosen element with `elementIndex`, before the Tester's remaining fallback locators. For other actions, the Tester is told which element to use.
|
|
44
|
+
- **Identical options:** when two matches look the same, the question is not asked. The Tester is told to click by appearance with `visualClick()`.
|
|
42
45
|
- **Fallback:** the Tester receives the numbered list of matches and picks one itself, narrows the locator, or uses a visual click.
|
|
43
46
|
|
|
44
47
|
### Pilot: progress review
|
|
45
48
|
|
|
46
|
-
Every few steps, Pilot reviews the Tester's recent actions with the `agenticModel`.
|
|
49
|
+
Every few steps, Pilot reviews the Tester's recent actions with the `agenticModel`. On a routine review it first asks whether the recent actions advance the scenario toward its remaining expected outcomes. The decision model sees the scenario, its planned steps, which expected outcomes are checked and which remain, the run log, the pages visited, the current page and the recent actions.
|
|
50
|
+
|
|
51
|
+
A review forced by a run of failed or empty steps skips the question and goes straight to Pilot.
|
|
47
52
|
|
|
48
53
|
- **Approved:** the review is skipped and the Tester continues. This saves one `agenticModel` call.
|
|
49
54
|
- **Fallback:** Pilot runs its full review as usual.
|
|
@@ -105,4 +110,10 @@ The model supplies only the question. The tool gathers the rest of the state its
|
|
|
105
110
|
|
|
106
111
|
## Tracing
|
|
107
112
|
|
|
108
|
-
Each question is recorded as a `judge.decide` span
|
|
113
|
+
Each question is recorded as a `judge.decide` span:
|
|
114
|
+
|
|
115
|
+
- **Input:** the exact request body sent to the provider: model, state and question. Post it again to the endpoint to replay the decision.
|
|
116
|
+
- **Output:** the raw answer and its probability, before the threshold is applied.
|
|
117
|
+
- **Metadata:** `judgeQuestion` for filtering by question, `judgeDecision` with the approved value, confidence, threshold and model, and `judgeEndpoint`.
|
|
118
|
+
|
|
119
|
+
A failed request marks the span as an error and records the reason. The TUI shows `⚖️ Asking judge...` while a question is in flight. See [Observability](../contributing/observability.md) for setting up tracing.
|
package/package.json
CHANGED
package/src/action.ts
CHANGED
|
@@ -114,6 +114,7 @@ class Action {
|
|
|
114
114
|
if (includeScreenshot) {
|
|
115
115
|
const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
|
|
116
116
|
const screenshotPath = join(statesDir, filename);
|
|
117
|
+
await page?.screencast?.hideOverlays().catch(() => {});
|
|
117
118
|
screenshotFile = await page
|
|
118
119
|
?.screenshot({ path: screenshotPath, fullPage: true })
|
|
119
120
|
.then(() => {
|
|
@@ -124,6 +125,7 @@ class Action {
|
|
|
124
125
|
debugLog('Screenshot failed, continuing without it:', err);
|
|
125
126
|
return undefined;
|
|
126
127
|
});
|
|
128
|
+
await page?.screencast?.showOverlays().catch(() => {});
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
// Save HTML to file
|
|
@@ -491,6 +493,14 @@ class Action {
|
|
|
491
493
|
}
|
|
492
494
|
}
|
|
493
495
|
|
|
496
|
+
public async attemptExactElementIndex(codeBlock: string, elementIndex: number, originalMessage?: string): Promise<boolean> {
|
|
497
|
+
const applyElementIndex = (step: any) => {
|
|
498
|
+
step.opts = { ...step.opts, elementIndex };
|
|
499
|
+
};
|
|
500
|
+
codeceptjs.event.dispatcher.once(codeceptjs.event.step.started, applyElementIndex);
|
|
501
|
+
return this.attempt(codeBlock, originalMessage).finally(() => codeceptjs.event.dispatcher.off(codeceptjs.event.step.started, applyElementIndex));
|
|
502
|
+
}
|
|
503
|
+
|
|
494
504
|
async exitIframe(): Promise<void> {
|
|
495
505
|
if (!this.playwrightHelper.frame) return;
|
|
496
506
|
debugLog('Switching to main frame');
|
|
@@ -8,6 +8,7 @@ import { tag } from '../../utils/logger.ts';
|
|
|
8
8
|
import { relativeToCwd } from '../../utils/next-steps.ts';
|
|
9
9
|
import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from '../../utils/step-analyzer.ts';
|
|
10
10
|
import { safeFilename } from '../../utils/strings.ts';
|
|
11
|
+
import { ensureCodeceptResolvable } from '../../utils/test-files.ts';
|
|
11
12
|
import type { Conversation } from '../conversation.ts';
|
|
12
13
|
import { ASSERTION_TOOLS } from '../tools.ts';
|
|
13
14
|
import type { Constructor } from './mixin.ts';
|
|
@@ -99,6 +100,7 @@ export function WithCodeceptJS<T extends Constructor>(Base: T) {
|
|
|
99
100
|
|
|
100
101
|
const testsDir = ConfigParser.getInstance().getTestsDir();
|
|
101
102
|
mkdirSync(testsDir, { recursive: true });
|
|
103
|
+
ensureCodeceptResolvable(testsDir);
|
|
102
104
|
|
|
103
105
|
const filePath = join(testsDir, safeFilename(plan.title, '.js'));
|
|
104
106
|
writeFileSync(filePath, lines.join('\n'));
|
package/src/ai/judge-provider.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { Observability } from '../observability.ts';
|
|
2
|
+
import { redactSecrets } from '../utils/secrets.ts';
|
|
3
|
+
|
|
1
4
|
const REQUEST_TIMEOUT_MS = 15000;
|
|
2
5
|
|
|
3
6
|
export class JudgeProvider {
|
|
@@ -21,6 +24,9 @@ export class JudgeProvider {
|
|
|
21
24
|
let q: Record<string, unknown> = { type: 'noul', instructions: question };
|
|
22
25
|
if (options) q = { type: 'choice', instructions: question, criteria: Object.fromEntries(options.map((option, index) => [String(index), option])) };
|
|
23
26
|
const body = JSON.stringify({ model: this.model, state, questions: { q } });
|
|
27
|
+
const span = Observability.getSpan();
|
|
28
|
+
span?.setAttribute('langfuse.observation.input', redactSecrets(body));
|
|
29
|
+
span?.setAttribute('ai.telemetry.metadata.judgeEndpoint', this.url);
|
|
24
30
|
const controller = new AbortController();
|
|
25
31
|
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
|
|
26
32
|
try {
|
package/src/ai/judge-tool.ts
CHANGED
|
@@ -11,7 +11,11 @@ export function createJudgeTool(deps: ToolDeps, buildState: () => Promise<Record
|
|
|
11
11
|
return {
|
|
12
12
|
judge: tool({
|
|
13
13
|
description: dedent`
|
|
14
|
-
|
|
14
|
+
Fast yes/no or pick-one decision about the current page, answered from <page_aria> and your recent steps.
|
|
15
|
+
No screenshot, no HTML, produces no test code.
|
|
16
|
+
Use for in-flow judgements you would otherwise guess: did the last action already take effect, which of several similar elements the scenario means, is this the expected page or dialog.
|
|
17
|
+
Not for final assertions (use verify/finish) and not for anything page_aria does not show, such as styling or visual state (use see).
|
|
18
|
+
Phrase the question literally and concretely.
|
|
15
19
|
`,
|
|
16
20
|
inputSchema: z.object({
|
|
17
21
|
question: z.string().describe('The statement to confirm, or the question the options answer'),
|
package/src/ai/judge.ts
CHANGED
|
@@ -9,6 +9,7 @@ const debugLog = createDebug('explorbot:judge');
|
|
|
9
9
|
const APPROVAL_THRESHOLD = 0.7;
|
|
10
10
|
|
|
11
11
|
export const UNDECIDED = 'undecided';
|
|
12
|
+
const UNDECIDED_HINT = `Choose "${UNDECIDED}" if you are not sure or no option fits.`;
|
|
12
13
|
export const JUDGE_PAGE_CAP = 12000;
|
|
13
14
|
|
|
14
15
|
export class Decision {
|
|
@@ -29,12 +30,15 @@ export class Decision {
|
|
|
29
30
|
export class Judge {
|
|
30
31
|
constructor(
|
|
31
32
|
private provider: JudgeProvider,
|
|
32
|
-
private enabled: { tool: boolean; direct: boolean }
|
|
33
|
+
private enabled: { tool: boolean; direct: boolean },
|
|
34
|
+
private threshold = APPROVAL_THRESHOLD
|
|
33
35
|
) {}
|
|
34
36
|
|
|
35
37
|
static fromConfig(config: AIConfig['decisionModel']): Judge | null {
|
|
36
38
|
if (!config) return null;
|
|
37
|
-
|
|
39
|
+
const threshold = config.threshold ?? APPROVAL_THRESHOLD;
|
|
40
|
+
if (!(threshold > 0 && threshold < 1)) throw new Error(`decisionModel.threshold must be between 0 and 1, got ${config.threshold}`);
|
|
41
|
+
return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false }, threshold);
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
get toolEnabled(): boolean {
|
|
@@ -46,12 +50,21 @@ export class Judge {
|
|
|
46
50
|
return this.consult(question, options, state);
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
async pick(question: string, options: string[], state: unknown): Promise<number | null> {
|
|
54
|
+
if (new Set(options).size < options.length) return null;
|
|
55
|
+
const decision = await this.decide(question, [...options, UNDECIDED], state);
|
|
56
|
+
if (!decision.value) return null;
|
|
57
|
+
return options.indexOf(decision.value) + 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
async consult(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision> {
|
|
50
61
|
if (Array.isArray(options) && options.length < 2) return new Decision(null, 0);
|
|
51
62
|
return Observability.run('judge.decide', { tags: ['judge'] }, async () => {
|
|
52
63
|
setActivity('⚖️ Asking judge...', 'ai');
|
|
53
64
|
const decision = await this.request(question, options, state).finally(() => clearActivity());
|
|
54
|
-
Observability.getSpan()
|
|
65
|
+
const span = Observability.getSpan();
|
|
66
|
+
span?.setAttribute('ai.telemetry.metadata.judgeQuestion', question);
|
|
67
|
+
span?.setAttribute('ai.telemetry.metadata.judgeDecision', JSON.stringify({ question, value: decision.value, confidence: decision.confidence, threshold: this.threshold, model: this.provider.model }));
|
|
55
68
|
return decision;
|
|
56
69
|
});
|
|
57
70
|
}
|
|
@@ -59,17 +72,23 @@ export class Judge {
|
|
|
59
72
|
private async request(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision> {
|
|
60
73
|
let list: string[] | undefined;
|
|
61
74
|
if (Array.isArray(options)) list = options;
|
|
75
|
+
let instructions = question;
|
|
76
|
+
if (list?.includes(UNDECIDED)) instructions = `${question} ${UNDECIDED_HINT}`;
|
|
62
77
|
|
|
63
|
-
const answer = await this.provider.decide(state,
|
|
78
|
+
const answer = await this.provider.decide(state, instructions, list).catch((error: unknown) => this.recordFailure(error));
|
|
64
79
|
if (!answer) return new Decision(null, 0);
|
|
65
|
-
|
|
80
|
+
Observability.getSpan()?.setAttribute('langfuse.observation.output', JSON.stringify(answer));
|
|
81
|
+
if (answer.probability <= this.threshold) return new Decision(null, answer.probability);
|
|
66
82
|
if (answer.value === UNDECIDED) return new Decision(null, answer.probability);
|
|
67
83
|
return new Decision(answer.value, answer.probability);
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
private recordFailure(error: unknown): null {
|
|
71
87
|
debugLog('judge declined: %s', error);
|
|
72
|
-
Observability.getSpan()
|
|
88
|
+
const span = Observability.getSpan();
|
|
89
|
+
span?.setAttribute('ai.telemetry.metadata.judgeError', String(error));
|
|
90
|
+
span?.setAttribute('langfuse.observation.level', 'ERROR');
|
|
91
|
+
span?.setAttribute('langfuse.observation.status_message', String(error));
|
|
73
92
|
return null;
|
|
74
93
|
}
|
|
75
94
|
}
|
package/src/ai/pilot.ts
CHANGED
|
@@ -19,7 +19,7 @@ import type { Agent, AgentDeps } from './agent.ts';
|
|
|
19
19
|
import type { Conversation } from './conversation.ts';
|
|
20
20
|
import type { Fisherman } from './fisherman.ts';
|
|
21
21
|
import { createAskApiTool } from './fisherman/tools.ts';
|
|
22
|
-
import { type Judge, UNDECIDED } from './judge.ts';
|
|
22
|
+
import { JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
|
|
23
23
|
import type { Navigator } from './navigator.ts';
|
|
24
24
|
import type { Provider } from './provider.ts';
|
|
25
25
|
import type { Researcher } from './researcher.ts';
|
|
@@ -542,6 +542,22 @@ export class Pilot implements Agent {
|
|
|
542
542
|
);
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
async periodicAnalyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string> {
|
|
546
|
+
const toolCalls = testerConversation.getToolExecutions().slice(-this.stepsToReview);
|
|
547
|
+
const healthy = await this.judge?.decide('The recent actions advance the scenario toward its remaining expected outcomes.', null, {
|
|
548
|
+
scenario: task.scenario,
|
|
549
|
+
plannedSteps: task.plannedSteps,
|
|
550
|
+
expectations: this.formatExpectations(task),
|
|
551
|
+
runLog: task.notesToString() || 'No steps recorded.',
|
|
552
|
+
visitedUrls: task.getVisitedUrls({ localOnly: true }),
|
|
553
|
+
state: this.buildStateContext(currentState),
|
|
554
|
+
page: currentState.getCompactARIA().slice(0, JUDGE_PAGE_CAP),
|
|
555
|
+
recentActions: this.formatActions(toolCalls),
|
|
556
|
+
});
|
|
557
|
+
if (healthy?.approved) return '';
|
|
558
|
+
return this.analyzeProgress(task, currentState, testerConversation);
|
|
559
|
+
}
|
|
560
|
+
|
|
545
561
|
async analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string> {
|
|
546
562
|
tag('substep').log('Pilot analyzing progress...');
|
|
547
563
|
|
|
@@ -555,9 +571,6 @@ export class Pilot implements Agent {
|
|
|
555
571
|
const actionsContext = this.formatActions(toolCalls);
|
|
556
572
|
const stateContext = this.buildStateContext(currentState);
|
|
557
573
|
|
|
558
|
-
const healthy = await this.judge?.decide('The run is moving toward the goal and can continue without a supervisor reviewing it now.', null, { scenario: task.scenario, state: stateContext, recentActions: actionsContext });
|
|
559
|
-
if (healthy?.approved) return '';
|
|
560
|
-
|
|
561
574
|
const hasFailures = toolCalls.length === 0 || toolCalls.some((t) => !t.wasSuccessful);
|
|
562
575
|
|
|
563
576
|
const text = await this.sendToPilot(
|
|
@@ -1149,6 +1162,9 @@ export class Pilot implements Agent {
|
|
|
1149
1162
|
const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
|
|
1150
1163
|
if (consoleError) line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1151
1164
|
|
|
1165
|
+
const disabledReason = t.output?.disabledReason?.[0];
|
|
1166
|
+
if (disabledReason) line += `\n shown on hover of disabled target: ${disabledReason.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1167
|
+
|
|
1152
1168
|
return line;
|
|
1153
1169
|
})
|
|
1154
1170
|
.join('\n\n');
|
package/src/ai/provider.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
1
2
|
import { OpenTelemetry } from '@ai-sdk/otel';
|
|
2
3
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
3
4
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
4
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
-
import dedent from 'dedent';
|
|
6
5
|
import { APICallError, NoObjectGeneratedError, asSchema, extractJsonMiddleware, generateObject, generateText, isStepCount, parsePartialJson, registerTelemetry, tool, wrapLanguageModel } from 'ai';
|
|
7
6
|
import type { ModelMessage } from 'ai';
|
|
7
|
+
import dedent from 'dedent';
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { clearActivity, setActivity } from '../activity.ts';
|
|
10
10
|
import { type AIConfig, configuredModels, modelName as getModelName } from '../config.js';
|
|
@@ -60,14 +60,15 @@ function extractCachedTokens(usage: any): number {
|
|
|
60
60
|
return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function abortAfterIdle(ms: number,
|
|
63
|
+
function abortAfterIdle(ms: number, state: { cancelled: boolean; timedOut: boolean }, controller: AbortController, busy: { tools: number }): Promise<never> {
|
|
64
64
|
return new Promise((_, reject) => {
|
|
65
65
|
const tick = () => {
|
|
66
|
-
if (
|
|
66
|
+
if (state.cancelled) return;
|
|
67
67
|
if (executionController.isAwaitingInput() || busy.tools > 0) {
|
|
68
68
|
setTimeout(tick, ms);
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
|
+
state.timedOut = true;
|
|
71
72
|
controller.abort();
|
|
72
73
|
reject(new Error('AI request timeout'));
|
|
73
74
|
};
|
|
@@ -252,13 +253,18 @@ export class Provider {
|
|
|
252
253
|
}
|
|
253
254
|
|
|
254
255
|
private async raceWithIdleTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, timeoutMs: number, busy: { tools: number } = { tools: 0 }): Promise<T> {
|
|
255
|
-
const
|
|
256
|
+
const state = { cancelled: false, timedOut: false };
|
|
256
257
|
const controller = new AbortController();
|
|
257
258
|
const combinedSignal = combinedAbortSignal(controller);
|
|
258
259
|
try {
|
|
259
|
-
return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs,
|
|
260
|
+
return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, state, controller, busy)]);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (state.timedOut && !executionController.getAbortSignal()?.aborted) {
|
|
263
|
+
throw new Error('AI request timeout', { cause: error });
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
260
266
|
} finally {
|
|
261
|
-
|
|
267
|
+
state.cancelled = true;
|
|
262
268
|
}
|
|
263
269
|
}
|
|
264
270
|
|