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.
Files changed (46) hide show
  1. package/bin/explorbot-cli.ts +3 -0
  2. package/dist/bin/explorbot-cli.js +2 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/action.d.ts +1 -0
  5. package/dist/src/action.js +9 -0
  6. package/dist/src/ai/historian/codeceptjs.js +2 -0
  7. package/dist/src/ai/judge-provider.js +5 -0
  8. package/dist/src/ai/judge-tool.js +5 -1
  9. package/dist/src/ai/judge.d.ts +3 -1
  10. package/dist/src/ai/judge.js +29 -6
  11. package/dist/src/ai/pilot.d.ts +1 -0
  12. package/dist/src/ai/pilot.js +20 -4
  13. package/dist/src/ai/provider.js +14 -7
  14. package/dist/src/ai/rerunner.js +3 -1
  15. package/dist/src/ai/rules.js +1 -1
  16. package/dist/src/ai/tester.d.ts +1 -0
  17. package/dist/src/ai/tester.js +22 -4
  18. package/dist/src/ai/tools.js +34 -14
  19. package/dist/src/config.d.ts +11 -3
  20. package/dist/src/config.js +42 -6
  21. package/dist/src/explorbot.js +4 -0
  22. package/dist/src/explorer.d.ts +2 -0
  23. package/dist/src/explorer.js +33 -1
  24. package/dist/src/global-config.js +1 -1
  25. package/dist/src/utils/test-files.d.ts +5 -0
  26. package/dist/src/utils/test-files.js +28 -1
  27. package/docs/basics/providers.md +3 -2
  28. package/docs/reference/commands.md +12 -0
  29. package/docs/web-testing/decisions.md +18 -7
  30. package/package.json +1 -1
  31. package/src/action.ts +10 -0
  32. package/src/ai/historian/codeceptjs.ts +2 -0
  33. package/src/ai/judge-provider.ts +6 -0
  34. package/src/ai/judge-tool.ts +5 -1
  35. package/src/ai/judge.ts +25 -6
  36. package/src/ai/pilot.ts +20 -4
  37. package/src/ai/provider.ts +13 -7
  38. package/src/ai/rerunner.ts +3 -1
  39. package/src/ai/rules.ts +1 -1
  40. package/src/ai/tester.ts +23 -3
  41. package/src/ai/tools.ts +37 -21
  42. package/src/config.ts +49 -7
  43. package/src/explorbot.ts +2 -0
  44. package/src/explorer.ts +37 -2
  45. package/src/global-config.ts +1 -1
  46. package/src/utils/test-files.ts +27 -1
@@ -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
- const outputRoot = resolveOutputRoot(process.env.EXPLORBOT_URL || envUrl);
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, options);
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, options) {
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 (options?.baseUrl) {
429
+ if (this.runBase) {
402
430
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
403
- config.playwright.url = options.baseUrl;
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
+ }
@@ -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
  }
@@ -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>;
@@ -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 = defaultBaseUrl || process.env.EXPLORBOT_URL;
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 = '';
@@ -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 or a missing API key stops Explorbot at startup.
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% sure, 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
+ 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 **70%**, or
21
- - a choice picked a real option, not *undecided*, with a probability above **70%**.
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 of 70% or lower
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:** the Tester is told which element to use and how to target it with `elementIndex`.
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`. First it asks whether the run is moving toward its goal and can continue unsupervised for now.
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 with the question, the answer and its confidence. A failed request also records the reason. The TUI shows `⚖️ Asking judge...` while a question is in flight. See [Observability](../contributing/observability.md) for setting up tracing.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
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'));
@@ -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 {
@@ -11,7 +11,11 @@ export function createJudgeTool(deps: ToolDeps, buildState: () => Promise<Record
11
11
  return {
12
12
  judge: tool({
13
13
  description: dedent`
14
- Settle one judgement about the current page instead of guessing. Phrase it literally and concretely.
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
- return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false });
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()?.setAttribute('ai.telemetry.metadata.judgeDecision', JSON.stringify({ question, value: decision.value, confidence: decision.confidence }));
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, question, list).catch((error: unknown) => this.recordFailure(error));
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
- if (answer.probability <= APPROVAL_THRESHOLD) return new Decision(null, answer.probability);
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()?.setAttribute('ai.telemetry.metadata.judgeError', String(error));
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');
@@ -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, cancel: { cancelled: boolean }, controller: AbortController, busy: { tools: number }): Promise<never> {
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 (cancel.cancelled) return;
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 cancel = { cancelled: false };
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, cancel, controller, busy)]);
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
- cancel.cancelled = true;
267
+ state.cancelled = true;
262
268
  }
263
269
  }
264
270