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
@@ -56,6 +56,7 @@ interface CLIOptions {
56
56
  debug?: boolean;
57
57
  config?: string;
58
58
  path?: string;
59
+ baseUrl?: string;
59
60
  show?: boolean;
60
61
  headless?: boolean;
61
62
  incognito?: boolean;
@@ -66,6 +67,7 @@ interface CLIOptions {
66
67
  function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
67
68
  return {
68
69
  from,
70
+ baseUrl: options.baseUrl,
69
71
  verbose: options.verbose || options.debug,
70
72
  config: options.config,
71
73
  path: options.path,
@@ -83,6 +85,7 @@ function addCommonOptions(cmd: Command): Command {
83
85
  .option('--debug', 'Enable debug logging (same as --verbose)')
84
86
  .option('-c, --config <path>', 'Path to configuration file')
85
87
  .option('-p, --path <path>', 'Working directory path')
88
+ .option('--base-url <url>', 'Run against this URL: its path scopes relative paths (plans, commands, saved tests on rerun), its query params ride along with every page load')
86
89
  .option('-s, --show', 'Show browser window')
87
90
  .option('--headless', 'Run browser in headless mode')
88
91
  .option('--incognito', 'Run without recording experiences')
@@ -46,6 +46,7 @@ if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima') && !prin
46
46
  function buildExplorBotOptions(from, options) {
47
47
  return {
48
48
  from,
49
+ baseUrl: options.baseUrl,
49
50
  verbose: options.verbose || options.debug,
50
51
  config: options.config,
51
52
  path: options.path,
@@ -62,6 +63,7 @@ function addCommonOptions(cmd) {
62
63
  .option('--debug', 'Enable debug logging (same as --verbose)')
63
64
  .option('-c, --config <path>', 'Path to configuration file')
64
65
  .option('-p, --path <path>', 'Working directory path')
66
+ .option('--base-url <url>', 'Run against this URL: its path scopes relative paths (plans, commands, saved tests on rerun), its query params ride along with every page load')
65
67
  .option('-s, --show', 'Show browser window')
66
68
  .option('--headless', 'Run browser in headless mode')
67
69
  .option('--incognito', 'Run without recording experiences')
package/dist/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",
@@ -48,6 +48,7 @@ declare class Action {
48
48
  captureBrowserLogs(): Promise<any[]>;
49
49
  executeOnce(code: string, verbatim?: boolean): Promise<Action>;
50
50
  attempt(codeBlock: string, originalMessage?: string): Promise<boolean>;
51
+ attemptExactElementIndex(codeBlock: string, elementIndex: number, originalMessage?: string): Promise<boolean>;
51
52
  exitIframe(): Promise<void>;
52
53
  getActor(): CodeceptJS.I;
53
54
  getActionResult(): ActionResult | null;
@@ -105,6 +105,7 @@ class Action {
105
105
  if (includeScreenshot) {
106
106
  const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
107
107
  const screenshotPath = join(statesDir, filename);
108
+ await page?.screencast?.hideOverlays().catch(() => { });
108
109
  screenshotFile = await page
109
110
  ?.screenshot({ path: screenshotPath, fullPage: true })
110
111
  .then(() => {
@@ -115,6 +116,7 @@ class Action {
115
116
  debugLog('Screenshot failed, continuing without it:', err);
116
117
  return undefined;
117
118
  });
119
+ await page?.screencast?.showOverlays().catch(() => { });
118
120
  }
119
121
  // Save HTML to file
120
122
  const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
@@ -461,6 +463,13 @@ class Action {
461
463
  return false;
462
464
  }
463
465
  }
466
+ async attemptExactElementIndex(codeBlock, elementIndex, originalMessage) {
467
+ const applyElementIndex = (step) => {
468
+ step.opts = { ...step.opts, elementIndex };
469
+ };
470
+ codeceptjs.event.dispatcher.once(codeceptjs.event.step.started, applyElementIndex);
471
+ return this.attempt(codeBlock, originalMessage).finally(() => codeceptjs.event.dispatcher.off(codeceptjs.event.step.started, applyElementIndex));
472
+ }
464
473
  async exitIframe() {
465
474
  if (!this.playwrightHelper.frame)
466
475
  return;
@@ -6,6 +6,7 @@ import { tag } from "../../utils/logger.js";
6
6
  import { relativeToCwd } from "../../utils/next-steps.js";
7
7
  import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from "../../utils/step-analyzer.js";
8
8
  import { safeFilename } from "../../utils/strings.js";
9
+ import { ensureCodeceptResolvable } from "../../utils/test-files.js";
9
10
  import { ASSERTION_TOOLS } from "../tools.js";
10
11
  import { escapeString, getExecutionLabel } from "./utils.js";
11
12
  export function WithCodeceptJS(Base) {
@@ -79,6 +80,7 @@ export function WithCodeceptJS(Base) {
79
80
  }
80
81
  const testsDir = ConfigParser.getInstance().getTestsDir();
81
82
  mkdirSync(testsDir, { recursive: true });
83
+ ensureCodeceptResolvable(testsDir);
82
84
  const filePath = join(testsDir, safeFilename(plan.title, '.js'));
83
85
  writeFileSync(filePath, lines.join('\n'));
84
86
  this.savedFiles.add(filePath);
@@ -1,3 +1,5 @@
1
+ import { Observability } from "../observability.js";
2
+ import { redactSecrets } from "../utils/secrets.js";
1
3
  const REQUEST_TIMEOUT_MS = 15000;
2
4
  export class JudgeProvider {
3
5
  model;
@@ -19,6 +21,9 @@ export class JudgeProvider {
19
21
  if (options)
20
22
  q = { type: 'choice', instructions: question, criteria: Object.fromEntries(options.map((option, index) => [String(index), option])) };
21
23
  const body = JSON.stringify({ model: this.model, state, questions: { q } });
24
+ const span = Observability.getSpan();
25
+ span?.setAttribute('langfuse.observation.input', redactSecrets(body));
26
+ span?.setAttribute('ai.telemetry.metadata.judgeEndpoint', this.url);
22
27
  const controller = new AbortController();
23
28
  const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
24
29
  try {
@@ -9,7 +9,11 @@ export function createJudgeTool(deps, buildState) {
9
9
  return {
10
10
  judge: tool({
11
11
  description: dedent `
12
- Settle one judgement about the current page instead of guessing. Phrase it literally and concretely.
12
+ Fast yes/no or pick-one decision about the current page, answered from <page_aria> and your recent steps.
13
+ No screenshot, no HTML, produces no test code.
14
+ 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.
15
+ Not for final assertions (use verify/finish) and not for anything page_aria does not show, such as styling or visual state (use see).
16
+ Phrase the question literally and concretely.
13
17
  `,
14
18
  inputSchema: z.object({
15
19
  question: z.string().describe('The statement to confirm, or the question the options answer'),
@@ -15,13 +15,15 @@ export declare class Judge {
15
15
  tool: boolean;
16
16
  direct: boolean;
17
17
  };
18
+ threshold: number;
18
19
  constructor(provider: JudgeProvider, enabled: {
19
20
  tool: boolean;
20
21
  direct: boolean;
21
- });
22
+ }, threshold?: number);
22
23
  static fromConfig(config: AIConfig['decisionModel']): Judge | null;
23
24
  get toolEnabled(): boolean;
24
25
  decide(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
26
+ pick(question: string, options: string[], state: unknown): Promise<number | null>;
25
27
  consult(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
26
28
  request(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
27
29
  recordFailure(error: unknown): null;
@@ -5,6 +5,7 @@ import { JudgeProvider } from "./judge-provider.js";
5
5
  const debugLog = createDebug('explorbot:judge');
6
6
  const APPROVAL_THRESHOLD = 0.7;
7
7
  export const UNDECIDED = 'undecided';
8
+ const UNDECIDED_HINT = `Choose "${UNDECIDED}" if you are not sure or no option fits.`;
8
9
  export const JUDGE_PAGE_CAP = 12000;
9
10
  export class Decision {
10
11
  value;
@@ -23,14 +24,19 @@ export class Decision {
23
24
  export class Judge {
24
25
  provider;
25
26
  enabled;
26
- constructor(provider, enabled) {
27
+ threshold;
28
+ constructor(provider, enabled, threshold = APPROVAL_THRESHOLD) {
27
29
  this.provider = provider;
28
30
  this.enabled = enabled;
31
+ this.threshold = threshold;
29
32
  }
30
33
  static fromConfig(config) {
31
34
  if (!config)
32
35
  return null;
33
- return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false });
36
+ const threshold = config.threshold ?? APPROVAL_THRESHOLD;
37
+ if (!(threshold > 0 && threshold < 1))
38
+ throw new Error(`decisionModel.threshold must be between 0 and 1, got ${config.threshold}`);
39
+ return new Judge(new JudgeProvider(config.provider, config.model), { tool: config.tool !== false, direct: config.direct !== false }, threshold);
34
40
  }
35
41
  get toolEnabled() {
36
42
  return this.enabled.tool;
@@ -40,13 +46,23 @@ export class Judge {
40
46
  return new Decision(null, 0);
41
47
  return this.consult(question, options, state);
42
48
  }
49
+ async pick(question, options, state) {
50
+ if (new Set(options).size < options.length)
51
+ return null;
52
+ const decision = await this.decide(question, [...options, UNDECIDED], state);
53
+ if (!decision.value)
54
+ return null;
55
+ return options.indexOf(decision.value) + 1;
56
+ }
43
57
  async consult(question, options, state) {
44
58
  if (Array.isArray(options) && options.length < 2)
45
59
  return new Decision(null, 0);
46
60
  return Observability.run('judge.decide', { tags: ['judge'] }, async () => {
47
61
  setActivity('⚖️ Asking judge...', 'ai');
48
62
  const decision = await this.request(question, options, state).finally(() => clearActivity());
49
- Observability.getSpan()?.setAttribute('ai.telemetry.metadata.judgeDecision', JSON.stringify({ question, value: decision.value, confidence: decision.confidence }));
63
+ const span = Observability.getSpan();
64
+ span?.setAttribute('ai.telemetry.metadata.judgeQuestion', question);
65
+ span?.setAttribute('ai.telemetry.metadata.judgeDecision', JSON.stringify({ question, value: decision.value, confidence: decision.confidence, threshold: this.threshold, model: this.provider.model }));
50
66
  return decision;
51
67
  });
52
68
  }
@@ -54,10 +70,14 @@ export class Judge {
54
70
  let list;
55
71
  if (Array.isArray(options))
56
72
  list = options;
57
- const answer = await this.provider.decide(state, question, list).catch((error) => this.recordFailure(error));
73
+ let instructions = question;
74
+ if (list?.includes(UNDECIDED))
75
+ instructions = `${question} ${UNDECIDED_HINT}`;
76
+ const answer = await this.provider.decide(state, instructions, list).catch((error) => this.recordFailure(error));
58
77
  if (!answer)
59
78
  return new Decision(null, 0);
60
- if (answer.probability <= APPROVAL_THRESHOLD)
79
+ Observability.getSpan()?.setAttribute('langfuse.observation.output', JSON.stringify(answer));
80
+ if (answer.probability <= this.threshold)
61
81
  return new Decision(null, answer.probability);
62
82
  if (answer.value === UNDECIDED)
63
83
  return new Decision(null, answer.probability);
@@ -65,7 +85,10 @@ export class Judge {
65
85
  }
66
86
  recordFailure(error) {
67
87
  debugLog('judge declined: %s', error);
68
- 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));
69
92
  return null;
70
93
  }
71
94
  }
@@ -41,6 +41,7 @@ export declare class Pilot implements Agent {
41
41
  buildVerdictSystemPrompt(task: Test): string;
42
42
  planTest(task: Test, currentState: ActionResult): Promise<string>;
43
43
  reviewNewPage(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
44
+ periodicAnalyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
44
45
  analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
45
46
  settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]>;
46
47
  settleByJudge(task: Test, expectations: string[]): Promise<Map<string, SettledStatus>>;
@@ -11,7 +11,7 @@ import { createDebug, tag } from "../utils/logger.js";
11
11
  const debugLog = createDebug('explorbot:pilot');
12
12
  import { truncateJson } from "../utils/strings.js";
13
13
  import { createAskApiTool } from "./fisherman/tools.js";
14
- import { UNDECIDED } from "./judge.js";
14
+ import { JUDGE_PAGE_CAP, UNDECIDED } from "./judge.js";
15
15
  import { capabilityGroundingRule, dataProtectionRules } from "./rules.js";
16
16
  import { isInteractive } from "./task-agent.js";
17
17
  import { withdrawVisionTools } from "./tools.js";
@@ -477,6 +477,22 @@ export class Pilot {
477
477
  Tester holds no recipe for this page until you load one — open the <experience> entries whose titles fit a step you are about to instruct.
478
478
  `, 'pilot.reviewNewPage', { tools: true, maxToolRoundtrips: 2, task });
479
479
  }
480
+ async periodicAnalyzeProgress(task, currentState, testerConversation) {
481
+ const toolCalls = testerConversation.getToolExecutions().slice(-this.stepsToReview);
482
+ const healthy = await this.judge?.decide('The recent actions advance the scenario toward its remaining expected outcomes.', null, {
483
+ scenario: task.scenario,
484
+ plannedSteps: task.plannedSteps,
485
+ expectations: this.formatExpectations(task),
486
+ runLog: task.notesToString() || 'No steps recorded.',
487
+ visitedUrls: task.getVisitedUrls({ localOnly: true }),
488
+ state: this.buildStateContext(currentState),
489
+ page: currentState.getCompactARIA().slice(0, JUDGE_PAGE_CAP),
490
+ recentActions: this.formatActions(toolCalls),
491
+ });
492
+ if (healthy?.approved)
493
+ return '';
494
+ return this.analyzeProgress(task, currentState, testerConversation);
495
+ }
480
496
  async analyzeProgress(task, currentState, testerConversation) {
481
497
  tag('substep').log('Pilot analyzing progress...');
482
498
  if (!this.conversation) {
@@ -487,9 +503,6 @@ export class Pilot {
487
503
  const toolCalls = testerConversation.getToolExecutions().slice(-this.stepsToReview);
488
504
  const actionsContext = this.formatActions(toolCalls);
489
505
  const stateContext = this.buildStateContext(currentState);
490
- 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 });
491
- if (healthy?.approved)
492
- return '';
493
506
  const hasFailures = toolCalls.length === 0 || toolCalls.some((t) => !t.wasSuccessful);
494
507
  const text = await this.sendToPilot(dedent `
495
508
  START URL: ${task.startUrl}
@@ -1046,6 +1059,9 @@ export class Pilot {
1046
1059
  const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
1047
1060
  if (consoleError)
1048
1061
  line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
1062
+ const disabledReason = t.output?.disabledReason?.[0];
1063
+ if (disabledReason)
1064
+ line += `\n shown on hover of disabled target: ${disabledReason.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
1049
1065
  return line;
1050
1066
  })
1051
1067
  .join('\n\n');
@@ -1,9 +1,9 @@
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';
6
+ import dedent from 'dedent';
7
7
  import { z } from 'zod';
8
8
  import { clearActivity, setActivity } from "../activity.js";
9
9
  import { configuredModels, modelName as getModelName } from '../config.js';
@@ -51,15 +51,16 @@ const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum
51
51
  function extractCachedTokens(usage) {
52
52
  return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
53
53
  }
54
- function abortAfterIdle(ms, cancel, controller, busy) {
54
+ function abortAfterIdle(ms, state, controller, busy) {
55
55
  return new Promise((_, reject) => {
56
56
  const tick = () => {
57
- if (cancel.cancelled)
57
+ if (state.cancelled)
58
58
  return;
59
59
  if (executionController.isAwaitingInput() || busy.tools > 0) {
60
60
  setTimeout(tick, ms);
61
61
  return;
62
62
  }
63
+ state.timedOut = true;
63
64
  controller.abort();
64
65
  reject(new Error('AI request timeout'));
65
66
  };
@@ -232,14 +233,20 @@ export class Provider {
232
233
  });
233
234
  }
234
235
  async raceWithIdleTimeout(fn, timeoutMs, busy = { tools: 0 }) {
235
- const cancel = { cancelled: false };
236
+ const state = { cancelled: false, timedOut: false };
236
237
  const controller = new AbortController();
237
238
  const combinedSignal = combinedAbortSignal(controller);
238
239
  try {
239
- return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, cancel, controller, busy)]);
240
+ return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, state, controller, busy)]);
241
+ }
242
+ catch (error) {
243
+ if (state.timedOut && !executionController.getAbortSignal()?.aborted) {
244
+ throw new Error('AI request timeout', { cause: error });
245
+ }
246
+ throw error;
240
247
  }
241
248
  finally {
242
- cancel.cancelled = true;
249
+ state.cancelled = true;
243
250
  }
244
251
  }
245
252
  async recoverFromContextLength(error, messages, options, retry) {
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { relative, resolve } from 'node:path';
2
+ import { dirname, relative, resolve } from 'node:path';
3
3
  import { tool } from 'ai';
4
4
  import { createBashTool } from 'bash-tool';
5
5
  import chalk from 'chalk';
@@ -19,6 +19,7 @@ import { createDebug, tag } from "../utils/logger.js";
19
19
  import { loop } from "../utils/loop.js";
20
20
  import { RulesLoader } from "../utils/rules-loader.js";
21
21
  import { isInternalStep } from "../utils/step-analyzer.js";
22
+ import { ensureCodeceptResolvable } from "../utils/test-files.js";
22
23
  import { toolExecutionLabel } from "./conversation.js";
23
24
  import { actionRule, locatorRule, sectionContextRule } from "./rules.js";
24
25
  import { TaskAgent } from "./task-agent.js";
@@ -122,6 +123,7 @@ export class Rerunner extends TaskAgent {
122
123
  const mocha = codeceptjs.container.mocha();
123
124
  mocha.reporter(class {
124
125
  });
126
+ ensureCodeceptResolvable(dirname(absPath));
125
127
  mocha.files = [absPath];
126
128
  mocha.loadFiles();
127
129
  let testIndex = 0;
@@ -224,7 +224,7 @@ export const unexpectedPopupRule = dedent `
224
224
  If a modal/popup appeared that you didn't expect, dismiss it first before continuing with original task.
225
225
  If elements become hidden or unclickable (timeout errors on visible elements), a dialog or overlay may have appeared on top.
226
226
  If a click error mentions "intercepts pointer events", another element is covering the target — dismiss it first.
227
- If buttons are disabled unexpectedly, check if a popup is blocking interaction or if required form fields are empty.
227
+ If buttons are disabled unexpectedly, trust any explanation the app gives (tooltip, hint text) over your own guess; otherwise check for a blocking popup or empty required fields.
228
228
 
229
229
  Dismiss strategy (try in order):
230
230
  1. I.pressKey('Escape') — press Escape to dismiss
@@ -51,6 +51,7 @@ export declare class Tester extends TaskAgent implements Agent {
51
51
  success: boolean;
52
52
  }>;
53
53
  shouldAnalyzeProgress(iteration: number, currentState: ActionResult): boolean;
54
+ get isStruggling(): boolean;
54
55
  shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean;
55
56
  prepareInstructionsForNextStep(task: Test): Promise<string>;
56
57
  reinjectContextIfNeeded(iteration: number, currentState: ActionResult): Promise<string>;
@@ -285,7 +285,13 @@ export class Tester extends TaskAgent {
285
285
  nextStep += `\n\n${guidance}`;
286
286
  }
287
287
  else if (this.shouldAnalyzeProgress(iteration, currentState) && this.pilot) {
288
- const guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
288
+ let guidance;
289
+ if (this.isStruggling) {
290
+ guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
291
+ }
292
+ else {
293
+ guidance = await this.pilot.periodicAnalyzeProgress(task, currentState, conversation);
294
+ }
289
295
  if (guidance)
290
296
  nextStep += `\n\n${guidance}`;
291
297
  this.consecutiveFailures = 0;
@@ -432,9 +438,7 @@ export class Tester extends TaskAgent {
432
438
  this.regionTransitioned = false;
433
439
  return true;
434
440
  }
435
- if (this.consecutiveFailures >= 3)
436
- return true;
437
- if (this.consecutiveEmptyResults >= 2)
441
+ if (this.isStruggling)
438
442
  return true;
439
443
  if (iteration % this.progressCheckInterval !== 0)
440
444
  return false;
@@ -442,6 +446,9 @@ export class Tester extends TaskAgent {
442
446
  return false;
443
447
  return true;
444
448
  }
449
+ get isStruggling() {
450
+ return this.consecutiveFailures >= 3 || this.consecutiveEmptyResults >= 2;
451
+ }
445
452
  shouldStopForStalledExecution(task, previousState, toolExecutions) {
446
453
  if (task.hasFinished)
447
454
  return false;
@@ -675,6 +682,15 @@ export class Tester extends TaskAgent {
675
682
  return meta;
676
683
  }
677
684
  getSystemMessage() {
685
+ let judgeRule = '';
686
+ if (this.agentTools?.judge) {
687
+ judgeRule = dedent `
688
+ <judge_rule>
689
+ For a quick judgement about the current page (did the last action already take effect, which element the scenario means), ask judge() before see().
690
+ Keep see() for what only a screenshot shows.
691
+ </judge_rule>
692
+ `;
693
+ }
678
694
  return dedent `
679
695
  <role>
680
696
  You are a senior test automation engineer with expertise in CodeceptJS and exploratory testing.
@@ -774,6 +790,8 @@ export class Tester extends TaskAgent {
774
790
 
775
791
  ${capabilityGroundingRule}
776
792
 
793
+ ${judgeRule}
794
+
777
795
  ${dataProtectionRules}
778
796
 
779
797
  ${this.provider.getSystemPromptForAgent('tester', this.stateManager.getCurrentState()?.url) || ''}
@@ -14,7 +14,7 @@ import { pause } from '../utils/loop.js';
14
14
  import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
15
15
  import { WebElement } from "../utils/web-element.js";
16
16
  import { createJudgeTool } from "./judge-tool.js";
17
- import { JUDGE_PAGE_CAP, UNDECIDED } from "./judge.js";
17
+ import { JUDGE_PAGE_CAP } from "./judge.js";
18
18
  import { sectionContextRule } from "./rules.js";
19
19
  import { isInteractive } from "./task-agent.js";
20
20
  const debugLog = createDebug('explorbot:tools');
@@ -92,13 +92,20 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
92
92
  let ambiguityError = null;
93
93
  for (let i = 0; i < commands.length; i++) {
94
94
  const command = transformContainsCommand(commands[i]);
95
- const success = await action.attempt(command, explanation);
95
+ let success = await action.attempt(command, explanation);
96
96
  const attempt = { command, success };
97
97
  if (action.lastError)
98
98
  attempt.error = errorText(action.lastError);
99
99
  attempts.push(attempt);
100
- if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound')
100
+ if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') {
101
101
  ambiguityError = action.lastError;
102
+ if (judge) {
103
+ const labels = (await extractWebElements(ambiguityError))?.map((el) => el.label) || [];
104
+ const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
105
+ if (index)
106
+ success = await action.attemptExactElementIndex(command, index, explanation);
107
+ }
108
+ }
102
109
  if (success) {
103
110
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
104
111
  if (!hasObservablePageChange(toolResult)) {
@@ -123,11 +130,22 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
123
130
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, commands[0]);
124
131
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
125
132
  const suggestion = clickFailureSuggestion(attempts);
126
- return failedToolResult('click', 'All click commands failed', {
127
- ...toolResult,
128
- attempts,
129
- suggestion,
130
- }, ambiguityError || action.lastError, judge, explanation);
133
+ const data = { ...toolResult, attempts, suggestion };
134
+ const clickError = ambiguityError || action.lastError;
135
+ for (const disabled of attempts.filter((a) => a.error?.includes('not enabled'))) {
136
+ const hoverCommand = disabled.command.replace('I.click(', 'I.moveCursorTo(');
137
+ const beforeHover = ActionResult.fromState(stateManager.getCurrentState());
138
+ if (!(await action.attempt(hoverCommand, 'Hover the disabled element to reveal why it is disabled')))
139
+ continue;
140
+ const hoverResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(beforeHover, hoverCommand);
141
+ const shown = hoverResult.pageDiff?.messages;
142
+ if (!shown?.length)
143
+ break;
144
+ data.disabledReason = shown;
145
+ data.suggestion = `Element is DISABLED. Hovering it showed: "${shown.join(' | ')}". That is why — act on it or report it, do not guess another cause.`;
146
+ break;
147
+ }
148
+ return failedToolResult('click', 'All click commands failed', data, clickError);
131
149
  },
132
150
  }),
133
151
  hover: tool({
@@ -1148,11 +1166,11 @@ export async function failedToolResult(action, message, data, error, judge, inte
1148
1166
  result.suggestion = getMultipleElementsSuggestion();
1149
1167
  result.multipleElementsDetected = true;
1150
1168
  result.elements = formatElementList(matched);
1151
- const labels = (matched || []).map((element) => `${element.text || 'no text'} — ${element.html}`);
1152
- const pick = await judge?.decide('Which listed element does the intent name?', [...labels, UNDECIDED], { intent });
1153
- if (!pick?.value)
1169
+ if (!judge || !matched)
1170
+ return result;
1171
+ const index = await judge.pick(PICK_ELEMENT_QUESTION, matched.map((el) => el.label), { intent });
1172
+ if (!index)
1154
1173
  return result;
1155
- const index = labels.indexOf(pick.value) + 1;
1156
1174
  result.suggestion = `Element ${index} is the one meant. Repeat the action with step.opts({ elementIndex: ${index} }) as the last argument.`;
1157
1175
  return result;
1158
1176
  }
@@ -1170,6 +1188,7 @@ function getMultipleElementsSuggestion() {
1170
1188
  reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1171
1189
  A match reported as not visible can never be acted on — pick one that is.
1172
1190
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1191
+ If the matches cannot be told apart, click the one you mean by appearance with visualClick().
1173
1192
  If the list is missing, call xpathCheck() to see what the locator matches.
1174
1193
  `;
1175
1194
  }
@@ -1182,7 +1201,7 @@ export function withdrawVisionTools(tools) {
1182
1201
  export function clickFailureSuggestion(attempts) {
1183
1202
  const errors = attempts.map((a) => a.error || '');
1184
1203
  if (errors.some((e) => e.includes('not enabled'))) {
1185
- return 'Element exists but is DISABLED — clicking it again cannot work. A precondition is unmet: a required field is empty, nothing is selected, or a dialog is blocking. Satisfy it, then retry.';
1204
+ return 'Element exists but is DISABLED — clicking it again cannot work. Find the precondition the page shows as unmet, then retry.';
1186
1205
  }
1187
1206
  if (errors.some((e) => e.includes('intercepts pointer events'))) {
1188
1207
  return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
@@ -1206,6 +1225,7 @@ const MAX_DISAMBIGUATE_ELEMENTS = 10;
1206
1225
  const MAX_DISAMBIGUATE_TEXT = 80;
1207
1226
  const MAX_DISAMBIGUATE_HTML = 300;
1208
1227
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1228
+ const PICK_ELEMENT_QUESTION = 'Which listed element does the intent name?';
1209
1229
  async function extractWebElements(error) {
1210
1230
  if (!error || error.name !== 'MultipleElementsFound')
1211
1231
  return null;
@@ -1219,7 +1239,7 @@ async function extractWebElements(error) {
1219
1239
  const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1220
1240
  const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1221
1241
  const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
1222
- result.push({ xpath, html, text, visible });
1242
+ result.push({ xpath, html, text, visible, label: `${text || 'no text'} — ${html}` });
1223
1243
  }
1224
1244
  catch (e) {
1225
1245
  debugLog('Failed to get details for element %d: %s', i, e);
@@ -162,6 +162,7 @@ interface AIConfig {
162
162
  model: string;
163
163
  tool?: boolean;
164
164
  direct?: boolean;
165
+ threshold?: number;
165
166
  };
166
167
  retryAttempts?: number;
167
168
  retryDelay?: number;
@@ -242,6 +243,7 @@ export declare class ConfigParser {
242
243
  site: SiteRecord | null;
243
244
  siteStartPath: string;
244
245
  siteConfigPath: string | null;
246
+ runBase: RunBase | null;
245
247
  constructor();
246
248
  static loadEnv(filePath: string, keepExisting?: boolean): void;
247
249
  static recommendedModels(): Record<string, Record<string, string>>;
@@ -260,6 +262,9 @@ export declare class ConfigParser {
260
262
  isGlobalMode(): boolean;
261
263
  getSite(): SiteRecord | null;
262
264
  getSiteConfigPath(): string | null;
265
+ applyBasePath(target: string): string;
266
+ getBaseQuery(): string;
267
+ getBasePath(): string;
263
268
  resolveTargetPath(target?: string): string;
264
269
  getStatesDir(): string;
265
270
  getPlansDir(): string;
@@ -274,9 +279,7 @@ export declare class ConfigParser {
274
279
  buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig>;
275
280
  findConfigFile(): string | null;
276
281
  loadConfigModule(configPath: string): Promise<any>;
277
- resolveConfig(config: ExplorbotConfig, options?: {
278
- baseUrl?: string;
279
- }): ExplorbotConfig;
282
+ resolveConfig(config: ExplorbotConfig): ExplorbotConfig;
280
283
  validateConfig(config: ExplorbotConfig): void;
281
284
  getNestedValue(obj: any, path: string): any;
282
285
  mergeWithDefaults(config: Partial<ExplorbotConfig>): ExplorbotConfig;
@@ -301,6 +304,11 @@ export declare function resolveStateRoot(baseUrl: string, ephemeral?: boolean):
301
304
  export declare function materializeKnowledge(outputRoot: string): void;
302
305
  export declare function createModel(provider: string, modelId: string): Promise<any>;
303
306
  type ModelRole = 'model' | 'visionModel' | 'agenticModel';
307
+ interface RunBase {
308
+ origin: string;
309
+ path: string;
310
+ query: string;
311
+ }
304
312
  interface ConfiguredModel {
305
313
  name: string;
306
314
  provider: string;