explorbot 0.3.0 → 0.3.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.
Files changed (43) hide show
  1. package/bin/explorbot-cli.ts +3 -3
  2. package/dist/bin/explorbot-cli.js +3 -2
  3. package/dist/package.json +1 -1
  4. package/dist/src/action-result.d.ts +3 -0
  5. package/dist/src/action-result.js +3 -0
  6. package/dist/src/action.d.ts +1 -0
  7. package/dist/src/action.js +12 -1
  8. package/dist/src/ai/conversation.d.ts +1 -0
  9. package/dist/src/ai/conversation.js +3 -0
  10. package/dist/src/ai/driller.js +3 -1
  11. package/dist/src/ai/fisherman.js +1 -1
  12. package/dist/src/ai/pilot.js +14 -9
  13. package/dist/src/ai/provider.d.ts +5 -0
  14. package/dist/src/ai/provider.js +84 -19
  15. package/dist/src/ai/researcher/deep-analysis.js +2 -2
  16. package/dist/src/ai/tester.js +22 -6
  17. package/dist/src/commands/exit-command.js +1 -1
  18. package/dist/src/commands/explore-command.js +22 -17
  19. package/dist/src/config.d.ts +2 -1
  20. package/dist/src/explorbot.js +1 -0
  21. package/dist/src/state-manager.d.ts +2 -0
  22. package/dist/src/state-manager.js +3 -3
  23. package/dist/src/utils/html.d.ts +2 -1
  24. package/dist/src/utils/html.js +10 -4
  25. package/dist/src/utils/overlay.d.ts +24 -0
  26. package/dist/src/utils/overlay.js +43 -0
  27. package/package.json +1 -1
  28. package/src/action-result.ts +5 -0
  29. package/src/action.ts +15 -1
  30. package/src/ai/conversation.ts +3 -0
  31. package/src/ai/driller.ts +3 -1
  32. package/src/ai/fisherman.ts +1 -1
  33. package/src/ai/pilot.ts +15 -10
  34. package/src/ai/provider.ts +115 -43
  35. package/src/ai/researcher/deep-analysis.ts +2 -2
  36. package/src/ai/tester.ts +20 -7
  37. package/src/commands/exit-command.ts +1 -1
  38. package/src/commands/explore-command.ts +17 -14
  39. package/src/config.ts +2 -1
  40. package/src/explorbot.ts +1 -0
  41. package/src/state-manager.ts +4 -3
  42. package/src/utils/html.ts +13 -4
  43. package/src/utils/overlay.ts +51 -0
@@ -1,8 +1,10 @@
1
1
  import { OpenTelemetry } from '@ai-sdk/otel';
2
2
  import { LangfuseSpanProcessor } from '@langfuse/otel';
3
3
  import { NodeSDK } from '@opentelemetry/sdk-node';
4
- import { generateObject, generateText, isStepCount, registerTelemetry } from 'ai';
4
+ import { AsyncLocalStorage } from 'node:async_hooks';
5
+ import { generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
5
6
  import type { ModelMessage } from 'ai';
7
+ import { z } from 'zod';
6
8
  import { clearActivity, setActivity } from '../activity.ts';
7
9
  import { type AIConfig, configuredModels, modelName as getModelName } from '../config.js';
8
10
  import { executionController } from '../execution-controller.ts';
@@ -11,7 +13,7 @@ import { Stats } from '../stats.ts';
11
13
  import { createDebug, tag } from '../utils/logger.js';
12
14
  import { type RetryOptions, withRetry } from '../utils/retry.js';
13
15
  import { RulesLoader } from '../utils/rules-loader.ts';
14
- import { Conversation, toToolExecution } from './conversation.js';
16
+ import { Conversation, NARRATION_TOOL, toToolExecution } from './conversation.js';
15
17
 
16
18
  const debugLog = createDebug('explorbot:provider');
17
19
  const promptLog = createDebug('explorbot:provider:out');
@@ -20,7 +22,30 @@ const responseLog = createDebug('explorbot:provider:in');
20
22
  class AiError extends Error {}
21
23
  export class ContextLengthError extends Error {}
22
24
 
25
+ const DEFAULT_PARALLEL_REQUESTS = 4;
26
+
27
+ const modelSlotContext = new AsyncLocalStorage<boolean>();
28
+
29
+ const HARMONY_CHANNELS = ['commentary', 'analysis', 'final'];
30
+
31
+ function createHarmonyChannelFallbackTool() {
32
+ return tool({
33
+ description: 'Internal compatibility fallback for model channel output. Do not call directly.',
34
+ inputSchema: z.record(z.string(), z.any()),
35
+ execute: async () => ({ message: 'Noted. Continue with your next action.' }),
36
+ });
37
+ }
38
+
23
39
  let telemetryRegistered = false;
40
+ let beforeExitFlushHooked = false;
41
+ let activeOtelSdk: NodeSDK | null = null;
42
+
43
+ export async function flushTelemetry(): Promise<void> {
44
+ const sdk = activeOtelSdk;
45
+ activeOtelSdk = null;
46
+ if (!sdk) return;
47
+ await sdk.shutdown().catch((error) => debugLog(`Telemetry flush failed: ${error instanceof Error ? error.message : error}`));
48
+ }
24
49
 
25
50
  const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
26
51
 
@@ -79,6 +104,8 @@ export class Provider {
79
104
  };
80
105
 
81
106
  lastConversation: Conversation | null = null;
107
+ private activeModelCalls = 0;
108
+ private modelCallWaiters: (() => void)[] = [];
82
109
 
83
110
  constructor(config: AIConfig) {
84
111
  if (!config?.model) {
@@ -99,6 +126,10 @@ export class Provider {
99
126
  }
100
127
  }
101
128
 
129
+ async stop(): Promise<void> {
130
+ await flushTelemetry();
131
+ }
132
+
102
133
  getModelForAgent(agentName?: string): any {
103
134
  if (!agentName) {
104
135
  return this.config.model;
@@ -154,10 +185,28 @@ export class Provider {
154
185
  private getRetryOptions(options: any = {}): RetryOptions {
155
186
  return {
156
187
  ...this.defaultRetryOptions,
157
- maxAttempts: options.maxRetries || this.defaultRetryOptions.maxAttempts,
188
+ maxAttempts: options.maxRetries || this.config.retryAttempts || this.defaultRetryOptions.maxAttempts,
189
+ baseDelay: this.config.retryDelay || this.defaultRetryOptions.baseDelay,
158
190
  };
159
191
  }
160
192
 
193
+ private async withModelRequestSlot<T>(fn: () => Promise<T>): Promise<T> {
194
+ if (modelSlotContext.getStore()) return fn();
195
+ const limit = Math.max(1, this.config.maxParallelRequests ?? DEFAULT_PARALLEL_REQUESTS);
196
+ if (this.activeModelCalls >= limit || this.modelCallWaiters.length > 0) {
197
+ await new Promise<void>((resolve) => this.modelCallWaiters.push(resolve));
198
+ } else {
199
+ this.activeModelCalls++;
200
+ }
201
+ try {
202
+ return await modelSlotContext.run(true, fn);
203
+ } finally {
204
+ const next = this.modelCallWaiters.shift();
205
+ if (next) next();
206
+ else this.activeModelCalls--;
207
+ }
208
+ }
209
+
161
210
  private mergeProviderOptions(config: Record<string, any>, agentName?: string): Record<string, any> {
162
211
  if (!agentName) return config;
163
212
  const agentOptions = this.getProviderOptionsForAgent(agentName);
@@ -234,7 +283,12 @@ export class Provider {
234
283
  spanProcessors: [processor],
235
284
  instrumentations: [],
236
285
  });
286
+ activeOtelSdk = this.otelSdk;
237
287
  void this.otelSdk.start();
288
+ if (!beforeExitFlushHooked) {
289
+ process.on('beforeExit', () => void flushTelemetry());
290
+ beforeExitFlushHooked = true;
291
+ }
238
292
  if (!telemetryRegistered) {
239
293
  registerTelemetry(new OpenTelemetry());
240
294
  telemetryRegistered = true;
@@ -288,7 +342,7 @@ export class Provider {
288
342
  async invokeConversation(conversation: Conversation, tools?: any, options: any = {}): Promise<{ conversation: Conversation; response: any; toolExecutions?: any[] } | null> {
289
343
  const response = tools ? await this.generateWithTools(conversation.messages, conversation.model, tools, options) : await this.chat(conversation.messages, conversation.model, options);
290
344
 
291
- const responseMessages = response.response?.messages || [];
345
+ const responseMessages = response.responseMessages || [];
292
346
  if (responseMessages.length > 0) {
293
347
  conversation.messages.push(...responseMessages);
294
348
  tag('debug').log('Added', responseMessages.length, 'messages from response');
@@ -302,7 +356,7 @@ export class Provider {
302
356
  const toolResults = response.toolResults || [];
303
357
 
304
358
  const resultsById = new Map(toolResults.map((r: any) => [r.toolCallId, r]));
305
- const toolExecutions = toolCalls.map((call: any) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
359
+ const toolExecutions = toolCalls.filter((call: any) => call.toolName !== NARRATION_TOOL).map((call: any) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
306
360
 
307
361
  return { conversation, response, toolExecutions };
308
362
  }
@@ -316,21 +370,23 @@ export class Provider {
316
370
 
317
371
  promptLog(messages[messages.length - 1].content);
318
372
  try {
319
- const response = await withRetry(async () => {
320
- const result = await generateText({ messages, ...config });
321
- this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
322
- if (!result.text) {
323
- debugLog(result);
373
+ const response = await this.withModelRequestSlot(() =>
374
+ withRetry(async () => {
375
+ const result = await generateText({ messages, ...config });
376
+ this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
377
+ if (!result.text) {
378
+ debugLog(result);
379
+ if (result.finishReason === 'length') {
380
+ throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
381
+ }
382
+ throw new Error('No response text from AI');
383
+ }
324
384
  if (result.finishReason === 'length') {
325
- throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
385
+ debugLog('finishReason=length, response may be truncated');
326
386
  }
327
- throw new Error('No response text from AI');
328
- }
329
- if (result.finishReason === 'length') {
330
- debugLog('finishReason=length, response may be truncated');
331
- }
332
- return result;
333
- }, this.getRetryOptions(options));
387
+ return result;
388
+ }, this.getRetryOptions(options))
389
+ );
334
390
 
335
391
  clearActivity();
336
392
  responseLog(response.text);
@@ -356,7 +412,8 @@ export class Provider {
356
412
  setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
357
413
  promptLog(`Using model: ${modelName}`);
358
414
 
359
- const toolNames = Object.keys(tools || {});
415
+ const toolsWithCommentary = tools?.commentary ? tools : { ...tools, commentary: createHarmonyChannelFallbackTool() };
416
+ const toolNames = Object.keys(toolsWithCommentary || {});
360
417
  tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
361
418
  promptLog('Available tools:', toolNames);
362
419
  promptLog(messages[messages.length - 1].content);
@@ -365,17 +422,19 @@ export class Provider {
365
422
  const extraStop = options.stopWhen;
366
423
  const stopConditions: any[] = [isStepCount(maxRoundtrips)];
367
424
  if (extraStop) stopConditions.push(extraStop);
368
- const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
425
+ const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
369
426
  try {
370
- const response = await withRetry(async () => {
371
- const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
372
- this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
373
- const hasToolCall = (result.toolCalls?.length || 0) > 0;
374
- if (!result.text && !hasToolCall && result.finishReason === 'length') {
375
- throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
376
- }
377
- return result;
378
- }, this.getRetryOptions(options));
427
+ const response = await this.withModelRequestSlot(() =>
428
+ withRetry(async () => {
429
+ const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
430
+ this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
431
+ const hasToolCall = (result.toolCalls?.length || 0) > 0;
432
+ if (!result.text && !hasToolCall && result.finishReason === 'length') {
433
+ throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
434
+ }
435
+ return result;
436
+ }, this.getRetryOptions(options))
437
+ );
379
438
 
380
439
  clearActivity();
381
440
 
@@ -418,9 +477,11 @@ export class Provider {
418
477
 
419
478
  try {
420
479
  promptLog(messages[messages.length - 1].content);
421
- const response = await withRetry(async () => {
422
- return (await this.raceWithIdleTimeout((signal) => generateObject({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
423
- }, this.getRetryOptions(options));
480
+ const response = await this.withModelRequestSlot(() =>
481
+ withRetry(async () => {
482
+ return (await this.raceWithIdleTimeout((signal) => generateObject({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
483
+ }, this.getRetryOptions(options))
484
+ );
424
485
 
425
486
  clearActivity();
426
487
  responseLog(response.object);
@@ -572,8 +633,6 @@ export class Provider {
572
633
 
573
634
  setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
574
635
 
575
- const imageData = `data:image/png;base64,${image.toString()}`;
576
-
577
636
  const messages: ModelMessage[] = [
578
637
  {
579
638
  role: 'user',
@@ -585,7 +644,7 @@ export class Provider {
585
644
  {
586
645
  type: 'file',
587
646
  mediaType: 'image/png',
588
- data: imageData,
647
+ data: image,
589
648
  },
590
649
  ],
591
650
  },
@@ -602,12 +661,14 @@ export class Provider {
602
661
 
603
662
  try {
604
663
  promptLog(`Processing image with prompt: ${prompt}`);
605
- const response = await withRetry(async () => {
606
- return await generateText({
607
- messages,
608
- ...config,
609
- });
610
- }, this.getRetryOptions());
664
+ const response = await this.withModelRequestSlot(() =>
665
+ withRetry(async () => {
666
+ return await generateText({
667
+ messages,
668
+ ...config,
669
+ });
670
+ }, this.getRetryOptions())
671
+ );
611
672
 
612
673
  clearActivity();
613
674
  responseLog(response.text);
@@ -623,13 +684,13 @@ export class Provider {
623
684
  }
624
685
 
625
686
  hasVision(): boolean {
626
- return this.config.visionModel !== undefined;
687
+ return this.config.visionModel !== undefined && !Stats.visionDisabled;
627
688
  }
628
689
  }
629
690
 
630
691
  function repairToolCall(options: ToolCallRepairOptions): any | null {
631
692
  if (options.toolCall.toolName.includes('<|channel|>')) return repairChannelMarker(options);
632
- return null;
693
+ return repairHarmonyChannel(options);
633
694
  }
634
695
 
635
696
  function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
@@ -641,6 +702,17 @@ function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any |
641
702
  return { ...toolCall, toolName };
642
703
  }
643
704
 
705
+ function repairHarmonyChannel({ toolCall, tools }: ToolCallRepairOptions): any | null {
706
+ if (!HARMONY_CHANNELS.includes(toolCall.toolName)) return null;
707
+ if (!tools.commentary) return null;
708
+ let input = toolCall.input;
709
+ if (typeof input !== 'string' || !input.trim().startsWith('{')) {
710
+ input = JSON.stringify({ content: typeof input === 'string' ? input : JSON.stringify(input ?? null) });
711
+ }
712
+ tag('warning').log(`Repaired tool name '${toolCall.toolName}' → 'commentary'`);
713
+ return { ...toolCall, toolName: NARRATION_TOOL, input };
714
+ }
715
+
644
716
  export { AiError, Provider as AIProvider };
645
717
 
646
718
  type ToolCallRepairOptions = { toolCall: any; tools: any };
@@ -5,7 +5,7 @@ import { executionController } from '../../execution-controller.ts';
5
5
  import type Explorer from '../../explorer.ts';
6
6
  import type { StateManager } from '../../state-manager.js';
7
7
  import { WebPageState } from '../../state-manager.js';
8
- import { detectFocusArea, diffAriaSnapshots } from '../../utils/aria.ts';
8
+ import { diffAriaSnapshots } from '../../utils/aria.ts';
9
9
  import { extractCodeBlocks } from '../../utils/code-extractor.ts';
10
10
  import { tag } from '../../utils/logger.js';
11
11
  import { mdq } from '../../utils/markdown-query.ts';
@@ -86,7 +86,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
86
86
  }
87
87
 
88
88
  async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
89
- const focusArea = detectFocusArea(current.ariaSnapshot);
89
+ const focusArea = current.overlay;
90
90
  if (!focusArea.detected || !focusArea.name) return null;
91
91
  if (focusArea.type !== 'dialog' && focusArea.type !== 'modal') return null;
92
92
 
package/src/ai/tester.ts CHANGED
@@ -8,10 +8,9 @@ import { clearActivity, setActivity } from '../activity.ts';
8
8
  import type { RequestStore } from '../api/request-store.ts';
9
9
  import type { TestRun } from '../explorer.ts';
10
10
  import { Observability } from '../observability.ts';
11
- import type { StateTransition } from '../state-manager.ts';
11
+ import { type StateTransition, normalizeUrl } from '../state-manager.ts';
12
12
  import { Stats } from '../stats.ts';
13
13
  import { type Test, TestResult, type TestResultType } from '../test-plan.ts';
14
- import { detectFocusArea } from '../utils/aria.ts';
15
14
  import { ErrorPageError, isErrorPage } from '../utils/error-page.ts';
16
15
  import { createDebug, tag } from '../utils/logger.ts';
17
16
  import { loop } from '../utils/loop.ts';
@@ -100,7 +99,7 @@ export class Tester extends TaskAgent implements Agent {
100
99
 
101
100
  async test(task: Test, opts: TestOptions = {}): Promise<{ success: boolean }> {
102
101
  Stats.tests++;
103
- const state = this.stateManager.getCurrentState();
102
+ let state = this.stateManager.getCurrentState();
104
103
  if (!state) throw new Error('No state found');
105
104
 
106
105
  setActivity(`🧪 Testing: ${task.scenario}`, 'action');
@@ -122,7 +121,21 @@ export class Tester extends TaskAgent implements Agent {
122
121
  task.addObservation(`Network error: ${r.method} ${r.path} → ${r.status}`);
123
122
  });
124
123
 
125
- const initialState = ActionResult.fromState(state);
124
+ let initialState = ActionResult.fromState(state);
125
+ const currentUrl = state.fullUrl || state.url;
126
+ let startOnCurrentPage = opts.startOnCurrentPage;
127
+ if (isErrorPage(initialState) && !startOnCurrentPage && task.startUrl && normalizeUrl(currentUrl) !== normalizeUrl(task.startUrl)) {
128
+ debugLog(`Recovering from error page at ${currentUrl} by navigating to ${task.startUrl}`);
129
+ try {
130
+ await this.explorer.visit(task.startUrl);
131
+ state = this.stateManager.getCurrentState();
132
+ if (!state) throw new Error('No state found after navigating to test start URL');
133
+ initialState = ActionResult.fromState(state);
134
+ startOnCurrentPage = true;
135
+ } catch (error) {
136
+ debugLog(`Could not recover from error page: ${compactErrorMessage(error)}`);
137
+ }
138
+ }
126
139
  if (isErrorPage(initialState)) {
127
140
  task.start();
128
141
  this.testRun = await this.explorer.beginTest(task);
@@ -153,7 +166,7 @@ export class Tester extends TaskAgent implements Agent {
153
166
  expected: task.expected,
154
167
  },
155
168
  },
156
- async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, opts)
169
+ async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, { ...opts, startOnCurrentPage })
157
170
  );
158
171
  }
159
172
 
@@ -481,7 +494,7 @@ export class Tester extends TaskAgent implements Agent {
481
494
  <rules>
482
495
  Use tools ${this.ACTION_TOOLS.join(', ')} to interact with the page.
483
496
  Fall back to interact() when those fail, when the step needs a sequence of actions, or when your context is not enough to locate the element.
484
- Use tool names exactly as listed in this prompt. Do not invent combined tool names, aliases, or names with channel markers such as "commentary".
497
+ Use tool names exactly as listed in this prompt. Do not invent combined tool names or aliases.
485
498
  Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
486
499
  Do not do unsuccesful clicks again.
487
500
  Do not run same tool calls with same parameters again.
@@ -517,7 +530,7 @@ export class Tester extends TaskAgent implements Agent {
517
530
 
518
531
  let context = '';
519
532
 
520
- const focusArea = detectFocusArea(currentState.ariaSnapshot);
533
+ const focusArea = currentState.overlay;
521
534
 
522
535
  const focusedElement = currentState.focusedElement;
523
536
  if (focusedElement) {
@@ -11,7 +11,7 @@ export class ExitCommand extends BaseCommand {
11
11
 
12
12
  async execute(_args: string): Promise<void> {
13
13
  await this.explorBot.printSessionAnalysis();
14
- await this.explorBot.getExplorer().stop();
14
+ await this.explorBot.stop();
15
15
 
16
16
  if (Stats.hasActivity()) {
17
17
  await new Promise<void>((resolve) => {
@@ -63,23 +63,26 @@ export class ExploreCommand extends BaseCommand {
63
63
  return;
64
64
  }
65
65
 
66
- if (cfg.enabled) {
67
- await this.runReuseMode(mainUrl, feature, cfg);
68
- } else {
69
- await this.runFreshMode(mainUrl, feature, cfg.styles);
70
- }
66
+ try {
67
+ if (cfg.enabled) {
68
+ await this.runReuseMode(mainUrl, feature, cfg);
69
+ } else {
70
+ await this.runFreshMode(mainUrl, feature, cfg.styles);
71
+ }
71
72
 
72
- const mainPlan = this.completedPlans[0];
73
- if (mainPlan) this.explorBot.setCurrentPlan(mainPlan);
74
- if (this.dryRun) {
73
+ const mainPlan = this.completedPlans[0];
74
+ if (mainPlan) this.explorBot.setCurrentPlan(mainPlan);
75
+ if (this.dryRun) {
76
+ this.printResults();
77
+ return;
78
+ }
79
+ if (mainUrl) await this.explorBot.visit(mainUrl).catch((err) => tag('warning').log(`Could not return to ${mainUrl}: ${browserErrorMessage(err)}`));
80
+ const savedPath = this.explorBot.savePlans(this.completedPlans);
75
81
  this.printResults();
76
- return;
82
+ this.printNextSteps(savedPath);
83
+ } finally {
84
+ if (!this.dryRun) await this.explorBot.printSessionAnalysis();
77
85
  }
78
- if (mainUrl) await this.explorBot.visit(mainUrl).catch((err) => tag('warning').log(`Could not return to ${mainUrl}: ${browserErrorMessage(err)}`));
79
- const savedPath = this.explorBot.savePlans(this.completedPlans);
80
- this.printResults();
81
- await this.explorBot.printSessionAnalysis();
82
- this.printNextSteps(savedPath);
83
86
  }
84
87
 
85
88
  private originLabel(test: Test): string {
package/src/config.ts CHANGED
@@ -175,8 +175,9 @@ interface AIConfig {
175
175
  vision?: boolean;
176
176
  visionModel?: any;
177
177
  agenticModel?: any;
178
- maxAttempts?: number;
178
+ retryAttempts?: number;
179
179
  retryDelay?: number;
180
+ maxParallelRequests?: number;
180
181
  agents?: AgentsConfig;
181
182
  }
182
183
 
package/src/explorbot.ts CHANGED
@@ -142,6 +142,7 @@ export class ExplorBot {
142
142
  async stop(): Promise<void> {
143
143
  this.agents.quartermaster?.stop();
144
144
  await this.explorer?.stop();
145
+ await this.provider?.stop();
145
146
  }
146
147
 
147
148
  async visitInitialState(): Promise<void> {
@@ -1,8 +1,8 @@
1
1
  import { ActionResult, type FocusedElement } from './action-result.js';
2
2
  import type { ExperienceTracker } from './experience-tracker.js';
3
3
  import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
4
- import { detectFocusArea } from './utils/aria.js';
5
4
  import { createDebug, tag } from './utils/logger.js';
5
+ import { Overlay } from './utils/overlay.js';
6
6
  import { slugify } from './utils/strings.js';
7
7
  import { extractStatePath } from './utils/url-matcher.js';
8
8
 
@@ -49,6 +49,7 @@ export interface WebPageState {
49
49
  focusedElement?: FocusedElement | null;
50
50
  links?: Link[];
51
51
  verifications?: Record<string, boolean>;
52
+ overlay?: Overlay;
52
53
  }
53
54
 
54
55
  export interface StateTransition {
@@ -206,8 +207,8 @@ export class StateManager {
206
207
  }
207
208
 
208
209
  private hasDialogAppeared(previousState: WebPageState | null, newState: WebPageState): boolean {
209
- const prevFocus = detectFocusArea(previousState?.ariaSnapshot ?? null);
210
- const newFocus = detectFocusArea(newState.ariaSnapshot ?? null);
210
+ const prevFocus = previousState?.overlay ?? Overlay.fromAria(previousState?.ariaSnapshot ?? null);
211
+ const newFocus = newState.overlay ?? Overlay.fromAria(newState.ariaSnapshot ?? null);
211
212
  return !prevFocus.detected && newFocus.detected;
212
213
  }
213
214
 
package/src/utils/html.ts CHANGED
@@ -98,7 +98,6 @@ export const HTML_SELECTORS = {
98
98
  interactiveControl: 'button, a[href], input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [role="menuitem"]',
99
99
  labelLike: 'h1, h2, h3, h4, h5, h6, legend, caption, label, [role="heading"], [class*="title"], [class*="label"], [class*="header"], [class*="name"]',
100
100
  semanticContextContainer: 'section, article, form, fieldset, li, tr, td, th, [role="group"], [role="tabpanel"], [role="region"], [class*="card"], [class*="panel"], [class*="item"], [class*="usage"], [class*="group"]',
101
- semanticOverlays: ['[role="dialog"]', '[role="listbox"]', '[role="menu"]', '[role="tooltip"]:not([style*="display: none"]):not([style*="visibility: hidden"])'],
102
101
  } as const;
103
102
 
104
103
  export const HTML_VISIBILITY_LIMITS = {
@@ -163,7 +162,9 @@ export type VisibleOverlayExtractionConfig = {
163
162
  interactiveContentSelector: string;
164
163
  limits: typeof HTML_EXTRACTION_LIMITS;
165
164
  overlaySelectors: readonly string[];
165
+ overlaySemanticSelector: string;
166
166
  visibilityLimits: typeof HTML_VISIBILITY_LIMITS;
167
+ geometryFallback?: boolean;
167
168
  };
168
169
  export type ComponentScopeExtractionConfig = {
169
170
  eidxAttr: string;
@@ -479,20 +480,28 @@ export function extractVisibleOverlayHtml(config: VisibleOverlayExtractionConfig
479
480
  return interactiveCount > 0 || text.length > 0;
480
481
  }
481
482
 
482
- const overlays: string[] = [];
483
+ function isFloatingOverlay(element: Element): boolean {
484
+ const style = window.getComputedStyle(element as HTMLElement);
485
+ return style.position === 'fixed' || style.position === 'absolute' || Number.parseInt(style.zIndex || '0', 10) > 0;
486
+ }
487
+
483
488
  const seen = new Set<Element>();
489
+ const collected: Element[] = [];
484
490
  for (const selector of config.overlaySelectors) {
485
491
  for (const element of Array.from(document.querySelectorAll(selector))) {
486
492
  if (seen.has(element)) continue;
487
493
  seen.add(element);
488
494
  if (!isVisible(element)) continue;
495
+ if (!element.matches(config.overlaySemanticSelector) && !isFloatingOverlay(element)) continue;
489
496
  const { interactiveCount, text } = getUsefulContent(element);
490
497
  if (interactiveCount === 0 && text.length === 0) continue;
491
- overlays.push((element as HTMLElement).outerHTML.slice(0, config.limits.overlayHtmlLength));
498
+ collected.push(element);
492
499
  }
493
500
  }
494
501
 
495
- if (overlays.length === 0) {
502
+ const overlays = collected.filter((element) => !collected.some((other) => other !== element && element.contains(other))).map((element) => (element as HTMLElement).outerHTML.slice(0, config.limits.overlayHtmlLength));
503
+
504
+ if (overlays.length === 0 && config.geometryFallback !== false) {
496
505
  const floatingCandidates = Array.from(document.body.querySelectorAll('*'))
497
506
  .filter((element) => !seen.has(element) && isVisible(element) && isLikelyFloatingOverlay(element))
498
507
  .sort((left, right) => {
@@ -0,0 +1,51 @@
1
+ import { detectFocusArea } from './aria.js';
2
+ import { HTML_EXTRACTION_LIMITS, HTML_SELECTORS, HTML_VISIBILITY_LIMITS, type VisibleOverlayExtractionConfig, extractHeadings } from './html.js';
3
+
4
+ export const OVERLAY_SELECTORS = {
5
+ semanticOverlays: ['[role="dialog"]', '[role="listbox"]', '[role="menu"]', '[role="tooltip"]:not([style*="display: none"]):not([style*="visibility: hidden"])', '[class*="modal"]', '[class*="dialog"]', '[class*="overlay"]', '[class*="popup"]', '[class*="drawer"]', '[class*="lightbox"]'],
6
+ modalOverlays: ['[role="dialog"]', '[role="alertdialog"]', '[aria-modal="true"]', '[class*="modal"]', '[class*="dialog"]', '[class*="overlay"]', '[class*="popup"]', '[class*="drawer"]', '[class*="lightbox"]'],
7
+ overlaySemanticSelector: '[role="dialog"], [role="alertdialog"], [aria-modal="true"], [role="listbox"], [role="menu"], [role="tooltip"]',
8
+ } as const;
9
+
10
+ export type OverlayData = { type?: 'dialog' | 'modal' | null; name?: string | null };
11
+
12
+ export class Overlay {
13
+ readonly type: 'dialog' | 'modal' | null;
14
+ readonly name: string | null;
15
+
16
+ constructor(data: OverlayData = {}) {
17
+ this.type = data.type ?? null;
18
+ this.name = data.name ?? null;
19
+ }
20
+
21
+ get detected(): boolean {
22
+ return this.type !== null;
23
+ }
24
+
25
+ static fromHtml(html: string): Overlay {
26
+ const headings = extractHeadings(html);
27
+ const name = [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean).join(' ');
28
+ return new Overlay({ type: 'modal', name: name || null });
29
+ }
30
+
31
+ static fromAria(snapshot: string | null): Overlay {
32
+ return new Overlay(detectFocusArea(snapshot));
33
+ }
34
+
35
+ static resolve(data: { overlayHtml?: string; overlay?: OverlayData | null; ariaSnapshot?: string | null }): Overlay {
36
+ if (data.overlayHtml) return Overlay.fromHtml(data.overlayHtml);
37
+ if (data.overlay) return new Overlay(data.overlay);
38
+ return Overlay.fromAria(data.ariaSnapshot ?? null);
39
+ }
40
+
41
+ static captureConfig(): VisibleOverlayExtractionConfig {
42
+ return {
43
+ interactiveContentSelector: HTML_SELECTORS.interactiveContent,
44
+ limits: HTML_EXTRACTION_LIMITS,
45
+ overlaySelectors: OVERLAY_SELECTORS.modalOverlays,
46
+ overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
47
+ visibilityLimits: HTML_VISIBILITY_LIMITS,
48
+ geometryFallback: false,
49
+ };
50
+ }
51
+ }