explorbot 0.5.1 → 0.5.2

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/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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;
@@ -461,6 +461,13 @@ class Action {
461
461
  return false;
462
462
  }
463
463
  }
464
+ async attemptExactElementIndex(codeBlock, elementIndex, originalMessage) {
465
+ const applyElementIndex = (step) => {
466
+ step.opts = { ...step.opts, elementIndex };
467
+ };
468
+ codeceptjs.event.dispatcher.once(codeceptjs.event.step.started, applyElementIndex);
469
+ return this.attempt(codeBlock, originalMessage).finally(() => codeceptjs.event.dispatcher.off(codeceptjs.event.step.started, applyElementIndex));
470
+ }
464
471
  async exitIframe() {
465
472
  if (!this.playwrightHelper.frame)
466
473
  return;
@@ -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 {
@@ -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}
@@ -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) {
@@ -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;
@@ -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)) {
@@ -127,7 +134,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
127
134
  ...toolResult,
128
135
  attempts,
129
136
  suggestion,
130
- }, ambiguityError || action.lastError, judge, explanation);
137
+ }, ambiguityError || action.lastError);
131
138
  },
132
139
  }),
133
140
  hover: tool({
@@ -1148,11 +1155,11 @@ export async function failedToolResult(action, message, data, error, judge, inte
1148
1155
  result.suggestion = getMultipleElementsSuggestion();
1149
1156
  result.multipleElementsDetected = true;
1150
1157
  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)
1158
+ if (!judge || !matched)
1159
+ return result;
1160
+ const index = await judge.pick(PICK_ELEMENT_QUESTION, matched.map((el) => el.label), { intent });
1161
+ if (!index)
1154
1162
  return result;
1155
- const index = labels.indexOf(pick.value) + 1;
1156
1163
  result.suggestion = `Element ${index} is the one meant. Repeat the action with step.opts({ elementIndex: ${index} }) as the last argument.`;
1157
1164
  return result;
1158
1165
  }
@@ -1170,6 +1177,7 @@ function getMultipleElementsSuggestion() {
1170
1177
  reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1171
1178
  A match reported as not visible can never be acted on — pick one that is.
1172
1179
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1180
+ If the matches cannot be told apart, click the one you mean by appearance with visualClick().
1173
1181
  If the list is missing, call xpathCheck() to see what the locator matches.
1174
1182
  `;
1175
1183
  }
@@ -1206,6 +1214,7 @@ const MAX_DISAMBIGUATE_ELEMENTS = 10;
1206
1214
  const MAX_DISAMBIGUATE_TEXT = 80;
1207
1215
  const MAX_DISAMBIGUATE_HTML = 300;
1208
1216
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1217
+ const PICK_ELEMENT_QUESTION = 'Which listed element does the intent name?';
1209
1218
  async function extractWebElements(error) {
1210
1219
  if (!error || error.name !== 'MultipleElementsFound')
1211
1220
  return null;
@@ -1219,7 +1228,7 @@ async function extractWebElements(error) {
1219
1228
  const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1220
1229
  const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1221
1230
  const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
1222
- result.push({ xpath, html, text, visible });
1231
+ result.push({ xpath, html, text, visible, label: `${text || 'no text'} — ${html}` });
1223
1232
  }
1224
1233
  catch (e) {
1225
1234
  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;
@@ -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
 
@@ -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.2",
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
@@ -491,6 +491,14 @@ class Action {
491
491
  }
492
492
  }
493
493
 
494
+ public async attemptExactElementIndex(codeBlock: string, elementIndex: number, originalMessage?: string): Promise<boolean> {
495
+ const applyElementIndex = (step: any) => {
496
+ step.opts = { ...step.opts, elementIndex };
497
+ };
498
+ codeceptjs.event.dispatcher.once(codeceptjs.event.step.started, applyElementIndex);
499
+ return this.attempt(codeBlock, originalMessage).finally(() => codeceptjs.event.dispatcher.off(codeceptjs.event.step.started, applyElementIndex));
500
+ }
501
+
494
502
  async exitIframe(): Promise<void> {
495
503
  if (!this.playwrightHelper.frame) return;
496
504
  debugLog('Switching to main frame');
@@ -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.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(
@@ -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
 
package/src/ai/tester.ts CHANGED
@@ -324,7 +324,12 @@ export class Tester extends TaskAgent implements Agent {
324
324
  const guidance = await this.pilot.reviewNewPage(task, currentState, conversation);
325
325
  if (guidance) nextStep += `\n\n${guidance}`;
326
326
  } else if (this.shouldAnalyzeProgress(iteration, currentState) && this.pilot) {
327
- const guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
327
+ let guidance: string;
328
+ if (this.isStruggling) {
329
+ guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
330
+ } else {
331
+ guidance = await this.pilot.periodicAnalyzeProgress(task, currentState, conversation);
332
+ }
328
333
  if (guidance) nextStep += `\n\n${guidance}`;
329
334
  this.consecutiveFailures = 0;
330
335
  this.lastAnalyzedStateHash = currentState.hash;
@@ -486,13 +491,16 @@ export class Tester extends TaskAgent implements Agent {
486
491
  this.regionTransitioned = false;
487
492
  return true;
488
493
  }
489
- if (this.consecutiveFailures >= 3) return true;
490
- if (this.consecutiveEmptyResults >= 2) return true;
494
+ if (this.isStruggling) return true;
491
495
  if (iteration % this.progressCheckInterval !== 0) return false;
492
496
  if (this.lastAnalyzedStateHash === currentState.hash) return false;
493
497
  return true;
494
498
  }
495
499
 
500
+ private get isStruggling(): boolean {
501
+ return this.consecutiveFailures >= 3 || this.consecutiveEmptyResults >= 2;
502
+ }
503
+
496
504
  private shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean {
497
505
  if (task.hasFinished) return false;
498
506
 
package/src/ai/tools.ts CHANGED
@@ -16,7 +16,7 @@ import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/str
16
16
  import { WebElement } from '../utils/web-element.ts';
17
17
  import type { ToolDeps } from './agent.ts';
18
18
  import { createJudgeTool } from './judge-tool.ts';
19
- import { JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
19
+ import { JUDGE_PAGE_CAP, type Judge } from './judge.ts';
20
20
  import { Navigator } from './navigator.ts';
21
21
  import { Researcher } from './researcher.ts';
22
22
  import { sectionContextRule } from './rules.ts';
@@ -113,13 +113,20 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
113
113
 
114
114
  for (let i = 0; i < commands.length; i++) {
115
115
  const command = transformContainsCommand(commands[i]);
116
- const success = await action.attempt(command, explanation);
116
+ let success = await action.attempt(command, explanation);
117
117
 
118
118
  const attempt: { command: string; success: boolean; error?: string } = { command, success };
119
119
  if (action.lastError) attempt.error = errorText(action.lastError);
120
120
  attempts.push(attempt);
121
121
 
122
- if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') ambiguityError = action.lastError;
122
+ if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') {
123
+ ambiguityError = action.lastError;
124
+ if (judge) {
125
+ const labels = (await extractWebElements(ambiguityError))?.map((el) => el.label) || [];
126
+ const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
127
+ if (index) success = await action.attemptExactElementIndex(command, index, explanation);
128
+ }
129
+ }
123
130
 
124
131
  if (success) {
125
132
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
@@ -158,9 +165,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
158
165
  attempts,
159
166
  suggestion,
160
167
  },
161
- ambiguityError || action.lastError,
162
- judge,
163
- explanation
168
+ ambiguityError || action.lastError
164
169
  );
165
170
  },
166
171
  }),
@@ -1309,10 +1314,13 @@ export async function failedToolResult(action: string, message: string, data?: R
1309
1314
  result.suggestion = getMultipleElementsSuggestion();
1310
1315
  result.multipleElementsDetected = true;
1311
1316
  result.elements = formatElementList(matched);
1312
- const labels = (matched || []).map((element) => `${element.text || 'no text'} — ${element.html}`);
1313
- const pick = await judge?.decide('Which listed element does the intent name?', [...labels, UNDECIDED], { intent });
1314
- if (!pick?.value) return result;
1315
- const index = labels.indexOf(pick.value) + 1;
1317
+ if (!judge || !matched) return result;
1318
+ const index = await judge.pick(
1319
+ PICK_ELEMENT_QUESTION,
1320
+ matched.map((el) => el.label),
1321
+ { intent }
1322
+ );
1323
+ if (!index) return result;
1316
1324
  result.suggestion = `Element ${index} is the one meant. Repeat the action with step.opts({ elementIndex: ${index} }) as the last argument.`;
1317
1325
  return result;
1318
1326
  }
@@ -1333,6 +1341,7 @@ function getMultipleElementsSuggestion(): string {
1333
1341
  reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1334
1342
  A match reported as not visible can never be acted on — pick one that is.
1335
1343
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1344
+ If the matches cannot be told apart, click the one you mean by appearance with visualClick().
1336
1345
  If the list is missing, call xpathCheck() to see what the locator matches.
1337
1346
  `;
1338
1347
  }
@@ -1379,6 +1388,7 @@ const MAX_DISAMBIGUATE_ELEMENTS = 10;
1379
1388
  const MAX_DISAMBIGUATE_TEXT = 80;
1380
1389
  const MAX_DISAMBIGUATE_HTML = 300;
1381
1390
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1391
+ const PICK_ELEMENT_QUESTION = 'Which listed element does the intent name?';
1382
1392
 
1383
1393
  async function extractWebElements(error: Error | null | undefined): Promise<MatchedElement[] | null> {
1384
1394
  if (!error || error.name !== 'MultipleElementsFound') return null;
@@ -1393,7 +1403,7 @@ async function extractWebElements(error: Error | null | undefined): Promise<Matc
1393
1403
  const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1394
1404
  const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1395
1405
  const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
1396
- result.push({ xpath, html, text, visible });
1406
+ result.push({ xpath, html, text, visible, label: `${text || 'no text'} — ${html}` });
1397
1407
  } catch (e) {
1398
1408
  debugLog('Failed to get details for element %d: %s', i, e);
1399
1409
  }
@@ -1443,5 +1453,6 @@ interface MatchedElement {
1443
1453
  xpath: string;
1444
1454
  html: string;
1445
1455
  text: string;
1456
+ label: string;
1446
1457
  visible?: boolean;
1447
1458
  }
package/src/config.ts CHANGED
@@ -185,7 +185,7 @@ interface AIConfig {
185
185
  vision?: boolean;
186
186
  visionModel?: any;
187
187
  agenticModel?: any;
188
- decisionModel?: { provider: string; model: string; tool?: boolean; direct?: boolean };
188
+ decisionModel?: { provider: string; model: string; tool?: boolean; direct?: boolean; threshold?: number };
189
189
  retryAttempts?: number;
190
190
  retryDelay?: number;
191
191
  maxParallelRequests?: number;