explorbot 0.3.1 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +16 -5
- package/dist/bin/explorbot-cli.js +14 -4
- package/dist/models.json +2 -0
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +4 -0
- package/dist/src/action-result.js +20 -12
- package/dist/src/action.d.ts +11 -0
- package/dist/src/action.js +28 -11
- package/dist/src/ai/conversation.d.ts +2 -1
- package/dist/src/ai/conversation.js +9 -4
- package/dist/src/ai/driller.js +3 -1
- package/dist/src/ai/navigator.d.ts +3 -0
- package/dist/src/ai/navigator.js +20 -4
- package/dist/src/ai/pilot.js +9 -2
- package/dist/src/ai/provider.d.ts +2 -0
- package/dist/src/ai/provider.js +18 -1
- package/dist/src/ai/researcher/deep-analysis.js +7 -3
- package/dist/src/ai/researcher/sections.js +0 -1
- package/dist/src/ai/researcher.js +0 -1
- package/dist/src/ai/rules.js +0 -1
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +9 -11
- package/dist/src/ai/tools.d.ts +2 -0
- package/dist/src/ai/tools.js +21 -4
- package/dist/src/commands/exit-command.js +1 -1
- package/dist/src/commands/init-command.js +74 -24
- package/dist/src/components/InitWizard.d.ts +2 -1
- package/dist/src/components/InitWizard.js +8 -4
- package/dist/src/explorbot.js +1 -0
- package/dist/src/explorer.js +1 -0
- package/dist/src/knowledge-tracker.d.ts +3 -1
- package/dist/src/knowledge-tracker.js +4 -4
- package/dist/src/state-manager.d.ts +2 -0
- package/dist/src/state-manager.js +3 -3
- package/dist/src/utils/aria.js +1 -1
- package/dist/src/utils/html.d.ts +2 -1
- package/dist/src/utils/html.js +10 -4
- package/dist/src/utils/overlay.d.ts +24 -0
- package/dist/src/utils/overlay.js +43 -0
- package/docs/basics/providers.md +2 -4
- package/models.json +2 -0
- package/package.json +1 -1
- package/src/action-result.ts +25 -15
- package/src/action.ts +36 -13
- package/src/ai/conversation.ts +11 -5
- package/src/ai/driller.ts +3 -1
- package/src/ai/navigator.ts +22 -4
- package/src/ai/pilot.ts +9 -2
- package/src/ai/provider.ts +19 -1
- package/src/ai/researcher/deep-analysis.ts +8 -3
- package/src/ai/researcher/sections.ts +0 -1
- package/src/ai/researcher.ts +0 -1
- package/src/ai/rules.ts +0 -1
- package/src/ai/tester.ts +9 -9
- package/src/ai/tools.ts +20 -4
- package/src/commands/exit-command.ts +1 -1
- package/src/commands/init-command.ts +81 -22
- package/src/components/InitWizard.tsx +8 -4
- package/src/explorbot.ts +1 -0
- package/src/explorer.ts +1 -0
- package/src/knowledge-tracker.ts +4 -4
- package/src/state-manager.ts +4 -3
- package/src/utils/aria.ts +1 -1
- package/src/utils/html.ts +13 -4
- package/src/utils/overlay.ts +51 -0
package/dist/src/utils/html.js
CHANGED
|
@@ -79,7 +79,6 @@ export const HTML_SELECTORS = {
|
|
|
79
79
|
interactiveControl: 'button, a[href], input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [role="menuitem"]',
|
|
80
80
|
labelLike: 'h1, h2, h3, h4, h5, h6, legend, caption, label, [role="heading"], [class*="title"], [class*="label"], [class*="header"], [class*="name"]',
|
|
81
81
|
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"]',
|
|
82
|
-
semanticOverlays: ['[role="dialog"]', '[role="listbox"]', '[role="menu"]', '[role="tooltip"]:not([style*="display: none"]):not([style*="visibility: hidden"])'],
|
|
83
82
|
};
|
|
84
83
|
export const HTML_VISIBILITY_LIMITS = {
|
|
85
84
|
maxViewportOverlayRatio: 0.95,
|
|
@@ -447,8 +446,12 @@ export function extractVisibleOverlayHtml(config) {
|
|
|
447
446
|
const { interactiveCount, text } = getUsefulContent(element);
|
|
448
447
|
return interactiveCount > 0 || text.length > 0;
|
|
449
448
|
}
|
|
450
|
-
|
|
449
|
+
function isFloatingOverlay(element) {
|
|
450
|
+
const style = window.getComputedStyle(element);
|
|
451
|
+
return style.position === 'fixed' || style.position === 'absolute' || Number.parseInt(style.zIndex || '0', 10) > 0;
|
|
452
|
+
}
|
|
451
453
|
const seen = new Set();
|
|
454
|
+
const collected = [];
|
|
452
455
|
for (const selector of config.overlaySelectors) {
|
|
453
456
|
for (const element of Array.from(document.querySelectorAll(selector))) {
|
|
454
457
|
if (seen.has(element))
|
|
@@ -456,13 +459,16 @@ export function extractVisibleOverlayHtml(config) {
|
|
|
456
459
|
seen.add(element);
|
|
457
460
|
if (!isVisible(element))
|
|
458
461
|
continue;
|
|
462
|
+
if (!element.matches(config.overlaySemanticSelector) && !isFloatingOverlay(element))
|
|
463
|
+
continue;
|
|
459
464
|
const { interactiveCount, text } = getUsefulContent(element);
|
|
460
465
|
if (interactiveCount === 0 && text.length === 0)
|
|
461
466
|
continue;
|
|
462
|
-
|
|
467
|
+
collected.push(element);
|
|
463
468
|
}
|
|
464
469
|
}
|
|
465
|
-
|
|
470
|
+
const overlays = collected.filter((element) => !collected.some((other) => other !== element && element.contains(other))).map((element) => element.outerHTML.slice(0, config.limits.overlayHtmlLength));
|
|
471
|
+
if (overlays.length === 0 && config.geometryFallback !== false) {
|
|
466
472
|
const floatingCandidates = Array.from(document.body.querySelectorAll('*'))
|
|
467
473
|
.filter((element) => !seen.has(element) && isVisible(element) && isLikelyFloatingOverlay(element))
|
|
468
474
|
.sort((left, right) => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type VisibleOverlayExtractionConfig } from './html.js';
|
|
2
|
+
export declare const OVERLAY_SELECTORS: {
|
|
3
|
+
readonly semanticOverlays: readonly ["[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\"]"];
|
|
4
|
+
readonly modalOverlays: readonly ["[role=\"dialog\"]", "[role=\"alertdialog\"]", "[aria-modal=\"true\"]", "[class*=\"modal\"]", "[class*=\"dialog\"]", "[class*=\"overlay\"]", "[class*=\"popup\"]", "[class*=\"drawer\"]", "[class*=\"lightbox\"]"];
|
|
5
|
+
readonly overlaySemanticSelector: "[role=\"dialog\"], [role=\"alertdialog\"], [aria-modal=\"true\"], [role=\"listbox\"], [role=\"menu\"], [role=\"tooltip\"]";
|
|
6
|
+
};
|
|
7
|
+
export type OverlayData = {
|
|
8
|
+
type?: 'dialog' | 'modal' | null;
|
|
9
|
+
name?: string | null;
|
|
10
|
+
};
|
|
11
|
+
export declare class Overlay {
|
|
12
|
+
readonly type: 'dialog' | 'modal' | null;
|
|
13
|
+
readonly name: string | null;
|
|
14
|
+
constructor(data?: OverlayData);
|
|
15
|
+
get detected(): boolean;
|
|
16
|
+
static fromHtml(html: string): Overlay;
|
|
17
|
+
static fromAria(snapshot: string | null): Overlay;
|
|
18
|
+
static resolve(data: {
|
|
19
|
+
overlayHtml?: string;
|
|
20
|
+
overlay?: OverlayData | null;
|
|
21
|
+
ariaSnapshot?: string | null;
|
|
22
|
+
}): Overlay;
|
|
23
|
+
static captureConfig(): VisibleOverlayExtractionConfig;
|
|
24
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { detectFocusArea } from './aria.js';
|
|
2
|
+
import { HTML_EXTRACTION_LIMITS, HTML_SELECTORS, HTML_VISIBILITY_LIMITS, extractHeadings } from './html.js';
|
|
3
|
+
export const OVERLAY_SELECTORS = {
|
|
4
|
+
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"]'],
|
|
5
|
+
modalOverlays: ['[role="dialog"]', '[role="alertdialog"]', '[aria-modal="true"]', '[class*="modal"]', '[class*="dialog"]', '[class*="overlay"]', '[class*="popup"]', '[class*="drawer"]', '[class*="lightbox"]'],
|
|
6
|
+
overlaySemanticSelector: '[role="dialog"], [role="alertdialog"], [aria-modal="true"], [role="listbox"], [role="menu"], [role="tooltip"]',
|
|
7
|
+
};
|
|
8
|
+
export class Overlay {
|
|
9
|
+
type;
|
|
10
|
+
name;
|
|
11
|
+
constructor(data = {}) {
|
|
12
|
+
this.type = data.type ?? null;
|
|
13
|
+
this.name = data.name ?? null;
|
|
14
|
+
}
|
|
15
|
+
get detected() {
|
|
16
|
+
return this.type !== null;
|
|
17
|
+
}
|
|
18
|
+
static fromHtml(html) {
|
|
19
|
+
const headings = extractHeadings(html);
|
|
20
|
+
const name = [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean).join(' ');
|
|
21
|
+
return new Overlay({ type: 'modal', name: name || null });
|
|
22
|
+
}
|
|
23
|
+
static fromAria(snapshot) {
|
|
24
|
+
return new Overlay(detectFocusArea(snapshot));
|
|
25
|
+
}
|
|
26
|
+
static resolve(data) {
|
|
27
|
+
if (data.overlayHtml)
|
|
28
|
+
return Overlay.fromHtml(data.overlayHtml);
|
|
29
|
+
if (data.overlay)
|
|
30
|
+
return new Overlay(data.overlay);
|
|
31
|
+
return Overlay.fromAria(data.ariaSnapshot ?? null);
|
|
32
|
+
}
|
|
33
|
+
static captureConfig() {
|
|
34
|
+
return {
|
|
35
|
+
interactiveContentSelector: HTML_SELECTORS.interactiveContent,
|
|
36
|
+
limits: HTML_EXTRACTION_LIMITS,
|
|
37
|
+
overlaySelectors: OVERLAY_SELECTORS.modalOverlays,
|
|
38
|
+
overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
|
|
39
|
+
visibilityLimits: HTML_VISIBILITY_LIMITS,
|
|
40
|
+
geometryFallback: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
package/docs/basics/providers.md
CHANGED
|
@@ -154,14 +154,12 @@ Set the recommended model in the exported config:
|
|
|
154
154
|
```javascript
|
|
155
155
|
export default {
|
|
156
156
|
ai: {
|
|
157
|
+
model: anthropic('claude-haiku-4-5-20251001'),
|
|
158
|
+
visionModel: anthropic('claude-haiku-4-5-20251001'),
|
|
157
159
|
agenticModel: anthropic('claude-haiku-4-5-20251001'),
|
|
158
160
|
},
|
|
159
161
|
};
|
|
160
162
|
```
|
|
161
|
-
|
|
162
|
-
> [!NOTE]
|
|
163
|
-
> This provider currently doesn't serve `model` and `visionModel`, which is required for Explorbot to run at optimal cost and speed.
|
|
164
|
-
> It is recommended to pair it with another AI provider.
|
|
165
163
|
<!-- END provider:anthropic -->
|
|
166
164
|
|
|
167
165
|
### Azure OpenAI
|
package/models.json
CHANGED
package/package.json
CHANGED
package/src/action-result.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { TTLCache } from './utils/cache.ts';
|
|
|
7
7
|
import { type HtmlDiffPart, type HtmlDiffResult, htmlDiff, liveRegionMessages } from './utils/html-diff.ts';
|
|
8
8
|
import { extractHeadings, extractLinks, extractTargetedHtml, htmlCombinedSnapshot, htmlMinimalUISnapshot, htmlTextSnapshot, minifyHtml } from './utils/html.ts';
|
|
9
9
|
import { createDebug } from './utils/logger.ts';
|
|
10
|
+
import { Overlay } from './utils/overlay.ts';
|
|
10
11
|
import { slugify } from './utils/strings.ts';
|
|
11
12
|
import { extractStatePath, matchesUrl } from './utils/url-matcher.ts';
|
|
12
13
|
|
|
@@ -34,6 +35,7 @@ interface ActionResultData extends WebPageState {
|
|
|
34
35
|
focusedElement?: FocusedElement | null;
|
|
35
36
|
iframeURL?: string;
|
|
36
37
|
links?: Link[];
|
|
38
|
+
overlayHtml?: string;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export interface PageDiff {
|
|
@@ -86,6 +88,7 @@ export class ActionResult implements ActionResultData {
|
|
|
86
88
|
notes: string[] = [];
|
|
87
89
|
public links: Link[] = [];
|
|
88
90
|
public verifications?: Record<string, boolean>;
|
|
91
|
+
public overlay: Overlay = new Overlay();
|
|
89
92
|
|
|
90
93
|
constructor(data: ActionResultData) {
|
|
91
94
|
this.id = data.id;
|
|
@@ -131,6 +134,8 @@ export class ActionResult implements ActionResultData {
|
|
|
131
134
|
this._ariaSnapshot = data.ariaSnapshot;
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
this.overlay = Overlay.resolve(data);
|
|
138
|
+
|
|
134
139
|
if (!this.fullUrl && this.url) {
|
|
135
140
|
this.fullUrl = this.url;
|
|
136
141
|
}
|
|
@@ -545,17 +550,9 @@ export class ActionResult implements ActionResultData {
|
|
|
545
550
|
}
|
|
546
551
|
|
|
547
552
|
if (diff.htmlParts.length > 0) {
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const filteredHtml = htmlCombinedSnapshot(part.subtree, htmlConfig?.combined);
|
|
552
|
-
const minified = await minifyHtml(filteredHtml);
|
|
553
|
-
if (minified) {
|
|
554
|
-
processedParts.push({ ...part, subtree: minified });
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
if (processedParts.length > 0) {
|
|
558
|
-
pageDiff.htmlParts = collapseHtmlParts(processedParts);
|
|
553
|
+
const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
|
|
554
|
+
if (collapsed.length > 0) {
|
|
555
|
+
pageDiff.htmlParts = collapsed;
|
|
559
556
|
}
|
|
560
557
|
}
|
|
561
558
|
|
|
@@ -596,10 +593,12 @@ function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
|
|
|
596
593
|
const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
|
|
597
594
|
|
|
598
595
|
if (fullPageReRender) {
|
|
599
|
-
return parts
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
596
|
+
return parts
|
|
597
|
+
.filter((part) => part.added.length > 0 || part.removed.length > 0)
|
|
598
|
+
.map((part) => ({
|
|
599
|
+
...part,
|
|
600
|
+
subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
|
|
601
|
+
}));
|
|
603
602
|
}
|
|
604
603
|
|
|
605
604
|
return parts.map((part) => {
|
|
@@ -655,6 +654,17 @@ export class Diff {
|
|
|
655
654
|
return this._htmlDiffResult.parts;
|
|
656
655
|
}
|
|
657
656
|
|
|
657
|
+
async cleanedHtmlParts(): Promise<HtmlDiffPart[]> {
|
|
658
|
+
const htmlConfig = ConfigParser.getInstance().getConfig().html;
|
|
659
|
+
const cleaned: HtmlDiffPart[] = [];
|
|
660
|
+
for (const part of this.htmlParts) {
|
|
661
|
+
const minified = await minifyHtml(htmlCombinedSnapshot(part.subtree, htmlConfig?.combined));
|
|
662
|
+
if (!minified) continue;
|
|
663
|
+
cleaned.push({ ...part, subtree: minified });
|
|
664
|
+
}
|
|
665
|
+
return cleaned;
|
|
666
|
+
}
|
|
667
|
+
|
|
658
668
|
get ariaChanged(): string | null {
|
|
659
669
|
return this._ariaDiffResult;
|
|
660
670
|
}
|
package/src/action.ts
CHANGED
|
@@ -11,8 +11,9 @@ import { Observability } from './observability.ts';
|
|
|
11
11
|
import type { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
12
12
|
import type { StateManager } from './state-manager.js';
|
|
13
13
|
import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
|
|
14
|
-
import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
14
|
+
import { captureHtmlForSnapshot, getVisibleOverlayHtmlExtractorSource, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
15
15
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
16
|
+
import { Overlay } from './utils/overlay.js';
|
|
16
17
|
import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
|
|
17
18
|
import { safeFilename } from './utils/strings.ts';
|
|
18
19
|
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
|
|
@@ -36,6 +37,7 @@ class Action {
|
|
|
36
37
|
public playwrightHelper: any;
|
|
37
38
|
public playwrightGroupId: string | null = null;
|
|
38
39
|
public assertionSteps: Array<{ name: string; args: any[] }> = [];
|
|
40
|
+
public executedSteps: ExecutedStep[] = [];
|
|
39
41
|
public lastValue: unknown;
|
|
40
42
|
private recorder?: PlaywrightRecorder;
|
|
41
43
|
private recovery: RecoveryRunner;
|
|
@@ -144,11 +146,13 @@ class Action {
|
|
|
144
146
|
let ariaSnapshot: string | null = null;
|
|
145
147
|
let ariaSnapshotFile: string | undefined = undefined;
|
|
146
148
|
let focusedElement: FocusedElement | null = null;
|
|
149
|
+
let overlayHtml = '';
|
|
147
150
|
|
|
148
151
|
try {
|
|
149
152
|
const page = this.playwrightHelper.page;
|
|
150
153
|
ariaSnapshot = await page.locator('body').ariaSnapshot();
|
|
151
154
|
focusedElement = await page.evaluate(readFocusedElement);
|
|
155
|
+
if (!frame) overlayHtml = await this.captureOverlayHtml();
|
|
152
156
|
} catch (err) {
|
|
153
157
|
debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
|
|
154
158
|
}
|
|
@@ -177,6 +181,7 @@ class Action {
|
|
|
177
181
|
ariaSnapshot,
|
|
178
182
|
ariaSnapshotFile,
|
|
179
183
|
focusedElement,
|
|
184
|
+
overlayHtml: overlayHtml || undefined,
|
|
180
185
|
iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
|
|
181
186
|
});
|
|
182
187
|
this.stateManager.updateState(result, codeBlock);
|
|
@@ -190,6 +195,16 @@ class Action {
|
|
|
190
195
|
}
|
|
191
196
|
}
|
|
192
197
|
|
|
198
|
+
private async captureOverlayHtml(): Promise<string> {
|
|
199
|
+
return this.playwrightHelper.page.evaluate(
|
|
200
|
+
({ extractorSource, config }: { extractorSource: string; config: any }) => {
|
|
201
|
+
const extract = new Function(`return ${extractorSource}`)() as (config: any) => string;
|
|
202
|
+
return extract(config);
|
|
203
|
+
},
|
|
204
|
+
{ extractorSource: getVisibleOverlayHtmlExtractorSource(), config: Overlay.captureConfig() }
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
193
208
|
private async captureMainDocumentStatus(): Promise<number | undefined> {
|
|
194
209
|
if (this.mainDocumentStatus) return this.mainDocumentStatus;
|
|
195
210
|
|
|
@@ -315,9 +330,9 @@ class Action {
|
|
|
315
330
|
|
|
316
331
|
let codeString = code.replace(/^\(I\) => /, '').trim();
|
|
317
332
|
|
|
318
|
-
const executedSteps:
|
|
333
|
+
const executedSteps: ExecutedStep[] = [];
|
|
319
334
|
const assertionSteps: Array<{ name: string; args: any[] }> = [];
|
|
320
|
-
const
|
|
335
|
+
const detachSteps = attachStepLogger(executedSteps, assertionSteps);
|
|
321
336
|
const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
|
|
322
337
|
this.playwrightGroupId = groupId;
|
|
323
338
|
const detachResponses = this.captureResponses();
|
|
@@ -346,12 +361,13 @@ class Action {
|
|
|
346
361
|
await recorder.add(() => sleep(this.config.action?.delay || 500));
|
|
347
362
|
await recorder.promise();
|
|
348
363
|
this.lastValue = await returned;
|
|
364
|
+
if (!recorder.isRunning()) throw new Error('CodeceptJS recorder is stopped, commands were skipped and never reached the browser');
|
|
349
365
|
}
|
|
350
366
|
|
|
351
367
|
this.restorePageTimeout();
|
|
352
368
|
|
|
353
369
|
if (executedSteps.length > 0) {
|
|
354
|
-
codeString = executedSteps.join('\n');
|
|
370
|
+
codeString = executedSteps.map((step) => step.command).join('\n');
|
|
355
371
|
}
|
|
356
372
|
|
|
357
373
|
const pageState = await this.captureOnce({ codeBlock: codeString });
|
|
@@ -368,10 +384,11 @@ class Action {
|
|
|
368
384
|
this.assertionSteps = [];
|
|
369
385
|
throw err;
|
|
370
386
|
} finally {
|
|
387
|
+
this.executedSteps = executedSteps;
|
|
371
388
|
this.restorePageTimeout();
|
|
372
389
|
detachResponses();
|
|
373
390
|
if (groupId) await this.recorder!.endAction();
|
|
374
|
-
|
|
391
|
+
detachSteps();
|
|
375
392
|
if (stepSpan) {
|
|
376
393
|
stepSpan.end();
|
|
377
394
|
}
|
|
@@ -473,11 +490,13 @@ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeEl
|
|
|
473
490
|
|
|
474
491
|
type StepListener = (step: any, error?: any) => void;
|
|
475
492
|
|
|
476
|
-
const attachStepLogger = (target:
|
|
493
|
+
export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Array<{ name: string; args: any[] }>): (() => void) => {
|
|
477
494
|
const listener: StepListener = (step, error) => {
|
|
478
495
|
if (!step?.toCode) return;
|
|
479
496
|
if (step.name?.startsWith('grab')) return;
|
|
480
|
-
|
|
497
|
+
const executed: ExecutedStep = { command: step.toCode(), success: !error };
|
|
498
|
+
if (error) executed.error = errorToString(error);
|
|
499
|
+
target.push(executed);
|
|
481
500
|
if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
|
|
482
501
|
assertionsTarget.push({ name: step.name, args: step.args || [] });
|
|
483
502
|
}
|
|
@@ -489,12 +508,10 @@ const attachStepLogger = (target: string[], assertionsTarget?: Array<{ name: str
|
|
|
489
508
|
};
|
|
490
509
|
codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
|
|
491
510
|
codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
|
|
492
|
-
return
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
|
|
497
|
-
codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
|
|
511
|
+
return () => {
|
|
512
|
+
codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
|
|
513
|
+
codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
|
|
514
|
+
};
|
|
498
515
|
};
|
|
499
516
|
|
|
500
517
|
const readFocusedElement = () => {
|
|
@@ -516,3 +533,9 @@ const readFocusedElement = () => {
|
|
|
516
533
|
if (typeof value === 'string' && value) focused.value = value.slice(0, 200);
|
|
517
534
|
return focused;
|
|
518
535
|
};
|
|
536
|
+
|
|
537
|
+
export interface ExecutedStep {
|
|
538
|
+
command: string;
|
|
539
|
+
success: boolean;
|
|
540
|
+
error?: string;
|
|
541
|
+
}
|
package/src/ai/conversation.ts
CHANGED
|
@@ -5,12 +5,13 @@ export interface ToolExecution {
|
|
|
5
5
|
input: any;
|
|
6
6
|
output: any;
|
|
7
7
|
wasSuccessful: boolean;
|
|
8
|
+
reasoning?: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export function toToolExecution(toolName: string, input: any, rawOutput: any): ToolExecution {
|
|
11
|
+
export function toToolExecution(toolName: string, input: any, rawOutput: any, reasoning?: string): ToolExecution {
|
|
11
12
|
let output = rawOutput;
|
|
12
13
|
if (rawOutput?.type === 'json' && rawOutput?.value) output = rawOutput.value;
|
|
13
|
-
return { toolName, input, output, wasSuccessful: output?.success !== false };
|
|
14
|
+
return { toolName, input, output, wasSuccessful: output?.success !== false, reasoning };
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function toolExecutionLabel(input: Record<string, any> | undefined): string {
|
|
@@ -213,13 +214,17 @@ export class Conversation {
|
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
getToolExecutions(): ToolExecution[] {
|
|
216
|
-
const toolCalls = new Map<string, any>();
|
|
217
|
+
const toolCalls = new Map<string, { input: any; reasoning?: string }>();
|
|
217
218
|
for (const message of this.messages) {
|
|
218
219
|
if (message.role !== 'assistant') continue;
|
|
219
220
|
if (!Array.isArray(message.content)) continue;
|
|
221
|
+
const reasoning = message.content
|
|
222
|
+
.filter((part: any) => part.type === 'reasoning' && part.text?.trim())
|
|
223
|
+
.map((part: any) => part.text.trim())
|
|
224
|
+
.join('\n');
|
|
220
225
|
for (const part of message.content) {
|
|
221
226
|
if (part.type !== 'tool-call') continue;
|
|
222
|
-
toolCalls.set(part.toolCallId, part.input);
|
|
227
|
+
toolCalls.set(part.toolCallId, { input: part.input, reasoning });
|
|
223
228
|
}
|
|
224
229
|
}
|
|
225
230
|
|
|
@@ -230,7 +235,8 @@ export class Conversation {
|
|
|
230
235
|
for (const part of message.content) {
|
|
231
236
|
if (part.type !== 'tool-result') continue;
|
|
232
237
|
if (part.toolName === NARRATION_TOOL) continue;
|
|
233
|
-
|
|
238
|
+
const call = toolCalls.get(part.toolCallId);
|
|
239
|
+
executions.push(toToolExecution(part.toolName, call?.input || {}, part.output, call?.reasoning));
|
|
234
240
|
}
|
|
235
241
|
}
|
|
236
242
|
|
package/src/ai/driller.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from '../utils/html.ts';
|
|
23
23
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
24
24
|
import { loop, pause } from '../utils/loop.ts';
|
|
25
|
+
import { OVERLAY_SELECTORS } from '../utils/overlay.ts';
|
|
25
26
|
import { annotatePageElements } from '../utils/web-annotate.ts';
|
|
26
27
|
import { eidxInContainer } from '../utils/web-eidx.ts';
|
|
27
28
|
import { WebElement } from '../utils/web-element.ts';
|
|
@@ -682,7 +683,8 @@ export class Driller extends TaskAgent implements Agent {
|
|
|
682
683
|
config: {
|
|
683
684
|
interactiveContentSelector: HTML_SELECTORS.interactiveContent,
|
|
684
685
|
limits: HTML_EXTRACTION_LIMITS,
|
|
685
|
-
overlaySelectors:
|
|
686
|
+
overlaySelectors: OVERLAY_SELECTORS.semanticOverlays,
|
|
687
|
+
overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
|
|
686
688
|
visibilityLimits: HTML_VISIBILITY_LIMITS,
|
|
687
689
|
},
|
|
688
690
|
}
|
package/src/ai/navigator.ts
CHANGED
|
@@ -3,12 +3,13 @@ import dedent from 'dedent';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ActionResult } from '../action-result.js';
|
|
5
5
|
import type Action from '../action.ts';
|
|
6
|
+
import type { ExecutedStep } from '../action.ts';
|
|
6
7
|
import type { ExplorbotConfig } from '../config.ts';
|
|
7
8
|
import type { ExperienceTracker } from '../experience-tracker.js';
|
|
8
9
|
import Explorer from '../explorer.ts';
|
|
9
10
|
import type { KnowledgeTracker } from '../knowledge-tracker.js';
|
|
10
|
-
import { type StateManager, normalizeUrl } from '../state-manager.js';
|
|
11
11
|
import { renderAssertion } from '../playwright-recorder.ts';
|
|
12
|
+
import { type StateManager, normalizeUrl } from '../state-manager.js';
|
|
12
13
|
import { isFatalBrowserError } from '../utils/browser-errors.ts';
|
|
13
14
|
import { getCliName } from '../utils/cli-name.ts';
|
|
14
15
|
import { extractCodeBlocks } from '../utils/code-extractor.js';
|
|
@@ -37,6 +38,7 @@ class Navigator implements Agent {
|
|
|
37
38
|
|
|
38
39
|
private MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
|
|
39
40
|
lastFailureReason: string | null = null;
|
|
41
|
+
executedSteps: ExecutedStep[] = [];
|
|
40
42
|
|
|
41
43
|
private systemPrompt = dedent`
|
|
42
44
|
<role>
|
|
@@ -217,12 +219,18 @@ class Navigator implements Agent {
|
|
|
217
219
|
if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
|
|
218
220
|
|
|
219
221
|
this.lastFailureReason = null;
|
|
222
|
+
this.executedSteps = [];
|
|
220
223
|
tag('info').log('AI Navigator resolving state at', actionResult.url);
|
|
221
224
|
debugLog('Resolution message:', message);
|
|
222
225
|
|
|
223
226
|
const action = opts?.action ?? this.explorer.action();
|
|
224
227
|
const expectedUrl = opts?.expectedUrl;
|
|
225
228
|
|
|
229
|
+
if (expectedUrl && this.targetUrlReached(action, expectedUrl, actionResult)) {
|
|
230
|
+
tag('success').log(`Already at ${expectedUrl} — navigation resolved`);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
226
234
|
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
227
235
|
|
|
228
236
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
@@ -313,14 +321,19 @@ class Navigator implements Agent {
|
|
|
313
321
|
resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
|
|
314
322
|
|
|
315
323
|
if (!resolved && attempt.ok) {
|
|
316
|
-
|
|
324
|
+
if (check.urlMatches) {
|
|
325
|
+
lastFailure = `Reached ${check.freshState.url} but the page state did not change`;
|
|
326
|
+
tag('warning').log(`Page state did not change at ${check.freshState.url}`);
|
|
327
|
+
} else {
|
|
328
|
+
lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
|
|
329
|
+
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
|
|
330
|
+
}
|
|
317
331
|
batchFailures.push({
|
|
318
332
|
code: codeBlock,
|
|
319
333
|
error: lastFailure,
|
|
320
334
|
ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
|
|
321
335
|
urlAfter: check.freshState.url,
|
|
322
336
|
});
|
|
323
|
-
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
|
|
324
337
|
}
|
|
325
338
|
if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
|
|
326
339
|
progressBlocks.push(codeBlock);
|
|
@@ -456,6 +469,7 @@ class Navigator implements Agent {
|
|
|
456
469
|
|
|
457
470
|
debugLog(`Attempting resolution: ${codeBlock}`);
|
|
458
471
|
const ok = await action.attempt(codeBlock, message);
|
|
472
|
+
this.executedSteps.push(...action.executedSteps);
|
|
459
473
|
|
|
460
474
|
const page = action.playwrightHelper?.page;
|
|
461
475
|
if (page) {
|
|
@@ -484,11 +498,15 @@ class Navigator implements Agent {
|
|
|
484
498
|
}
|
|
485
499
|
|
|
486
500
|
const freshState = await this.explorer.capture();
|
|
487
|
-
const urlMatches = this.
|
|
501
|
+
const urlMatches = this.targetUrlReached(action, expectedUrl, freshState);
|
|
488
502
|
|
|
489
503
|
return { freshState, urlMatches };
|
|
490
504
|
}
|
|
491
505
|
|
|
506
|
+
private targetUrlReached(action: Action, expectedUrl: string, state: ActionResult): boolean {
|
|
507
|
+
return this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(state, expectedUrl));
|
|
508
|
+
}
|
|
509
|
+
|
|
492
510
|
private async ariaDiff(freshState: ActionResult, previous: ActionResult): Promise<string | null> {
|
|
493
511
|
if (freshState.getStateHash() === previous.getStateHash()) return null;
|
|
494
512
|
try {
|
package/src/ai/pilot.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { PlaywrightRecorder } from '../playwright-recorder.ts';
|
|
|
9
9
|
import type { StateManager } from '../state-manager.ts';
|
|
10
10
|
import { Stats } from '../stats.ts';
|
|
11
11
|
import { type Test, TestResult } from '../test-plan.ts';
|
|
12
|
-
import { collectInteractiveNodes
|
|
12
|
+
import { collectInteractiveNodes } from '../utils/aria.ts';
|
|
13
13
|
import { ErrorPageError } from '../utils/error-page.ts';
|
|
14
14
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
15
15
|
|
|
@@ -28,6 +28,7 @@ import { withdrawVisionTools } from './tools.ts';
|
|
|
28
28
|
const CHECK_TOOLS = ['verify', 'see', 'research'];
|
|
29
29
|
const EVIDENCE_TOOLS = ['verify', 'see'];
|
|
30
30
|
const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
31
|
+
const PILOT_REASONING_LIMIT = 500;
|
|
31
32
|
const PILOT_MESSAGE_LIMIT = 2;
|
|
32
33
|
const PILOT_MESSAGE_MAX_LENGTH = 160;
|
|
33
34
|
|
|
@@ -824,7 +825,7 @@ export class Pilot implements Agent {
|
|
|
824
825
|
lines.push(`h3: ${state.h3 || ''}`);
|
|
825
826
|
lines.push(`h4: ${state.h4 || ''}`);
|
|
826
827
|
|
|
827
|
-
const focusArea =
|
|
828
|
+
const focusArea = state.overlay;
|
|
828
829
|
if (focusArea.detected) {
|
|
829
830
|
lines.push(`modal: ${focusArea.name || focusArea.type}`);
|
|
830
831
|
} else {
|
|
@@ -1044,6 +1045,12 @@ export class Pilot implements Agent {
|
|
|
1044
1045
|
if (resultMessage) line += `\n result: ${resultMessage}`;
|
|
1045
1046
|
if (errorDetail && errorDetail !== resultMessage) line += `\n error: ${errorDetail}`;
|
|
1046
1047
|
|
|
1048
|
+
if (!t.wasSuccessful && t.reasoning) {
|
|
1049
|
+
let rationale = t.reasoning;
|
|
1050
|
+
if (rationale.length > PILOT_REASONING_LIMIT) rationale = `...${rationale.slice(-PILOT_REASONING_LIMIT)}`;
|
|
1051
|
+
line += `\n tester reasoned: ${rationale.replace(/\n+/g, ' ')}`;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1047
1054
|
const attempts = t.output?.attempts;
|
|
1048
1055
|
if (attempts && attempts.length > 1 && t.wasSuccessful) {
|
|
1049
1056
|
const failedBefore = attempts.filter((a: any) => !a.success);
|
package/src/ai/provider.ts
CHANGED
|
@@ -37,6 +37,15 @@ function createHarmonyChannelFallbackTool() {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
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
|
+
}
|
|
40
49
|
|
|
41
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'];
|
|
42
51
|
|
|
@@ -117,6 +126,10 @@ export class Provider {
|
|
|
117
126
|
}
|
|
118
127
|
}
|
|
119
128
|
|
|
129
|
+
async stop(): Promise<void> {
|
|
130
|
+
await flushTelemetry();
|
|
131
|
+
}
|
|
132
|
+
|
|
120
133
|
getModelForAgent(agentName?: string): any {
|
|
121
134
|
if (!agentName) {
|
|
122
135
|
return this.config.model;
|
|
@@ -270,7 +283,12 @@ export class Provider {
|
|
|
270
283
|
spanProcessors: [processor],
|
|
271
284
|
instrumentations: [],
|
|
272
285
|
});
|
|
286
|
+
activeOtelSdk = this.otelSdk;
|
|
273
287
|
void this.otelSdk.start();
|
|
288
|
+
if (!beforeExitFlushHooked) {
|
|
289
|
+
process.on('beforeExit', () => void flushTelemetry());
|
|
290
|
+
beforeExitFlushHooked = true;
|
|
291
|
+
}
|
|
274
292
|
if (!telemetryRegistered) {
|
|
275
293
|
registerTelemetry(new OpenTelemetry());
|
|
276
294
|
telemetryRegistered = true;
|
|
@@ -324,7 +342,7 @@ export class Provider {
|
|
|
324
342
|
async invokeConversation(conversation: Conversation, tools?: any, options: any = {}): Promise<{ conversation: Conversation; response: any; toolExecutions?: any[] } | null> {
|
|
325
343
|
const response = tools ? await this.generateWithTools(conversation.messages, conversation.model, tools, options) : await this.chat(conversation.messages, conversation.model, options);
|
|
326
344
|
|
|
327
|
-
const responseMessages = response.
|
|
345
|
+
const responseMessages = response.responseMessages || [];
|
|
328
346
|
if (responseMessages.length > 0) {
|
|
329
347
|
conversation.messages.push(...responseMessages);
|
|
330
348
|
tag('debug').log('Added', responseMessages.length, 'messages from response');
|
|
@@ -5,10 +5,11 @@ 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 {
|
|
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';
|
|
12
|
+
import { truncate } from '../../utils/strings.ts';
|
|
12
13
|
import type { Provider } from '../provider.js';
|
|
13
14
|
import { getCachedResearch, getPreviousResearch, saveResearch } from './cache.ts';
|
|
14
15
|
import { type Constructor, debugLog } from './mixin.ts';
|
|
@@ -16,6 +17,7 @@ import { type ResearchElement, parseResearchSections } from './parser.ts';
|
|
|
16
17
|
import type { ResearchResult } from './research-result.ts';
|
|
17
18
|
|
|
18
19
|
const DEFAULT_MAX_EXPANDABLE_CLICKS = 10;
|
|
20
|
+
const MAX_HTML_DIFF_CHARS = 20_000;
|
|
19
21
|
|
|
20
22
|
export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
21
23
|
return class extends Base {
|
|
@@ -86,7 +88,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
|
|
89
|
-
const focusArea =
|
|
91
|
+
const focusArea = current.overlay;
|
|
90
92
|
if (!focusArea.detected || !focusArea.name) return null;
|
|
91
93
|
if (focusArea.type !== 'dialog' && focusArea.type !== 'modal') return null;
|
|
92
94
|
|
|
@@ -486,6 +488,9 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
486
488
|
`;
|
|
487
489
|
}
|
|
488
490
|
|
|
491
|
+
const cleanedParts = await diff.cleanedHtmlParts();
|
|
492
|
+
const htmlChanges = truncate(cleanedParts.map((p) => `[Container: ${p.container}]\n${p.subtree}`).join('\n\n'), MAX_HTML_DIFF_CHARS);
|
|
493
|
+
|
|
489
494
|
const prompt = dedent`
|
|
490
495
|
${intro}
|
|
491
496
|
Analyze the changes and produce a UI map section.
|
|
@@ -494,7 +499,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
494
499
|
${diff.ariaChanged || 'none'}
|
|
495
500
|
|
|
496
501
|
HTML changes:
|
|
497
|
-
${
|
|
502
|
+
${htmlChanges || 'none'}
|
|
498
503
|
${alreadyHint}
|
|
499
504
|
|
|
500
505
|
Respond with a SINGLE section in this format:
|