Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## 2026-07-17

### Changes
- Research no longer discards whole sections of a page when a container matches more than one element. A container only narrows down where child elements are looked up, so a selector matching every navigation bar or every card on the page is now perfectly usable — each child element inside it is still required to be unique. Previously such a section was declared broken and every element in it was sent back to the AI for repair.
- When a container matches nothing on the page, Explorbot now repairs it by looking at the elements it found inside it and working out their real wrapper, instead of asking the AI to guess a new selector. The section is fixed instantly and keeps working locators for its elements.
- Research is significantly faster on pages where the AI picks an imprecise container selector. Previously, if more than 80% of the researched locators failed validation, Explorbot threw away the finished UI map and regenerated it from scratch — roughly doubling the time spent on the page. Because a single bad container marks all of its child elements broken, this triggered easily, and the re-research usually just corrected the container anyway. Broken locators now go straight to the repair step that fixes them in place, cutting research on affected pages by about a third.
- Fixed startup failing with `AI connection failed: Invalid 'max_output_tokens'` on models that reject a one-token response. The check that verifies your AI credentials at startup no longer caps the reply length, so it works with every provider regardless of their minimum.

Expand Down
11 changes: 5 additions & 6 deletions src/ai/researcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,10 @@ export class Researcher extends ResearcherBase implements Agent {
}

if (!interrupted()) {
const containerLocs = result.containerLocators;
await this.testLocators(containerLocs);
const brokenContainers = containerLocs.filter((l) => l.valid === false);
if (containerLocs.length > 0 && brokenContainers.length === containerLocs.length && retriesLeft > 0) {
tag('warning').log(`All ${containerLocs.length} containers broken, retrying research (${maxRetries - retriesLeft + 1}/${maxRetries})...`);
const brokenContainers = await this.resolveContainers(result);
const containerCount = result.containers.length;
if (containerCount > 0 && brokenContainers.length === containerCount && retriesLeft > 0) {
tag('warning').log(`All ${containerCount} containers broken, retrying research (${maxRetries - retriesLeft + 1}/${maxRetries})...`);
await new Promise((r) => setTimeout(r, 2000));
return this.research(state, { ...opts, force: true, _retriesLeft: retriesLeft - 1 } as any);
}
Expand Down Expand Up @@ -246,7 +245,7 @@ export class Researcher extends ResearcherBase implements Agent {
const validContainers = extractValidContainers(result.text);
result.parseLocators();
const freshContainerLocs = result.containerLocators;
await this.testLocators(freshContainerLocs);
await this.testLocators(freshContainerLocs, { scope: true });
const freshBroken = freshContainerLocs.filter((l) => l.valid === false).map((l) => l.locator);
const containers = validContainers.filter((c) => !freshBroken.includes(c.css));
await this.visuallyAnnotateElements({ containers });
Expand Down
60 changes: 58 additions & 2 deletions src/ai/researcher/locators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { parseAriaLocator } from '../../utils/aria.ts';
import { tag } from '../../utils/logger.js';
import { mdq } from '../../utils/markdown-query.ts';
import { WebElement } from '../../utils/web-element.ts';
import { isDynamicId } from '../../utils/xpath.ts';
import type { Conversation } from '../conversation.ts';
import type { Provider } from '../provider.js';
import { locatorRule as generalLocatorRuleText } from '../rules.js';
Expand Down Expand Up @@ -38,7 +39,7 @@ export function WithLocators<T extends Constructor>(Base: T) {
declare provider: Provider;
declare actionResult: ActionResult | undefined;

async testLocators(locators: Locator[]): Promise<void> {
async testLocators(locators: Locator[], opts: { scope?: boolean } = {}): Promise<void> {
let broken = 0;
for (const loc of locators) {
if (executionController.isInterrupted()) break;
Expand All @@ -65,6 +66,7 @@ export function WithLocators<T extends Constructor>(Base: T) {
return base.locator(loc.locator);
});
loc.valid = count === 1;
if (opts.scope && count > 1) loc.valid = true;
loc.pwLocator = buildPwLocatorString(loc);
if (!loc.valid) {
loc.error = count === 0 ? '0 elements' : `${count} elements`;
Expand Down Expand Up @@ -209,6 +211,59 @@ export function WithLocators<T extends Constructor>(Base: T) {
await this.validateContainers(result);
}

async resolveContainers(result: ResearchResult): Promise<Locator[]> {
const containerLocs = result.containerLocators;
await this.testLocators(containerLocs, { scope: true });
for (const loc of containerLocs.filter((l) => l.valid === false)) {
if (await this.recoverContainerFromChildren(result, loc.locator)) loc.valid = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returns true when only one section sharing this CSS is recovered, marking the container valid for all sections. Track recovery per section or return true only when every matching section is recovered

}
return containerLocs.filter((l) => l.valid === false);
}

private async recoverContainerFromChildren(result: ResearchResult, css: string): Promise<boolean> {
const sections = parseResearchSections(result.text).filter((s) => s.containerCss === css);
let recovered = false;

for (const section of sections) {
const eidxList = section.elements.map((el) => el.eidx).filter(Boolean) as string[];
if (eidxList.length < 2) continue;

const ancestor = await this.explorer.runWithBrowserRecovery('recoverContainerFromChildren', () => WebElement.commonAncestor(this.explorer.playwrightHelper.page, eidxList));
if (!ancestor) continue;

const candidates: string[] = [];
const id = ancestor.attrs.id;
if (id && !isDynamicId(id)) candidates.push(`#${id}`);
for (const cls of ancestor.filteredClasses) {
if (!/^[\w-]+$/.test(cls)) continue;
candidates.push(`${ancestor.tag}.${cls}`);
}

let unique: string | null = null;
let multiple: string | null = null;
for (const candidate of candidates) {
let count = 0;
try {
count = await this.explorer.playwrightLocatorCount((page) => page.locator(candidate));
} catch {}
if (count === 1) {
unique = candidate;
break;
}
if (count > 1 && !multiple) multiple = candidate;
}

const newCss = unique || multiple;
if (!newCss) continue;

debugLog(`Recovered container: '${css}' → '${newCss}' in "${section.name}"`);
this.updateSectionContainer(result, section, newCss);
recovered = true;
}

return recovered;
}

private async validateContainers(result: ResearchResult): Promise<void> {
const sections = parseResearchSections(result.text);

Expand Down Expand Up @@ -275,7 +330,8 @@ export interface Locator {
}

export interface LocatorMethods {
testLocators(locators: Locator[]): Promise<void>;
testLocators(locators: Locator[], opts?: { scope?: boolean }): Promise<void>;
fixBrokenSections(result: ResearchResult, conversation: Conversation): Promise<void>;
backfillBrokenLocators(result: ResearchResult): Promise<void>;
resolveContainers(result: ResearchResult): Promise<Locator[]>;
}
30 changes: 30 additions & 0 deletions src/utils/web-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,36 @@ export class WebElement {
return rawList.map((d) => WebElement.fromRawData(d));
}

static async commonAncestor(page: any, eidxList: string[]): Promise<WebElement | null> {
const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
if (validEidxList.length < 2) return null;

const raw: RawElementData | null = await page.evaluate(
([list, extractFnStr, config]: [string[], string, ElementExtractionConfig]) => {
const extract = new Function(`return ${extractFnStr}`)() as (el: Element, cfg: ElementExtractionConfig) => any;
const els: Element[] = [];
for (const eidx of list) {
const el = document.querySelector(`[${config.attrs.eidx}="${eidx}"]`);
if (el && !els.includes(el)) els.push(el);
}
if (els.length < 2) return null;

const containsAll = (node: Element) => els.every((el) => node.contains(el));
let ancestor: Element | null = els[0].parentElement;
while (ancestor && !containsAll(ancestor)) ancestor = ancestor.parentElement;
if (!ancestor) return null;

const tag = ancestor.tagName.toLowerCase();
if (tag === 'body' || tag === 'html') return null;
return extract(ancestor, config);
},
[validEidxList, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG] as [string[], string, ElementExtractionConfig]
);

if (!raw) return null;
return WebElement.fromRawData(raw);
}

static async findByXPath(html: string, xpath: string): Promise<{ totalFound: number; elements: WebElement[]; error?: string }> {
const result = await evaluateXPath(html, xpath);
if (result.error) return { totalFound: 0, elements: [], error: result.error };
Expand Down
65 changes: 64 additions & 1 deletion tests/unit/web-element.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { describe, expect, it } from 'bun:test';
import { afterEach, describe, expect, it } from 'bun:test';
import { JSDOM } from 'jsdom';
import { WebElement, extractElementData } from '../../src/utils/web-element.ts';

const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');

afterEach(() => {
restoreGlobal('window', originalWindow);
restoreGlobal('document', originalDocument);
});

describe('extractElementData', () => {
it('adds context, area, and variant hints for component drilling', () => {
const dom = new JSDOM(`
Expand Down Expand Up @@ -92,6 +100,61 @@ describe('WebElement', () => {
});
});

describe('WebElement.commonAncestor', () => {
it('returns the closest wrapper containing all elements', async () => {
const dom = new JSDOM(`
<main>
<div class="detail detail-view-resizable">
<button data-explorbot-eidx="e1">Save</button>
<span><a data-explorbot-eidx="e2" href="/cancel">Cancel</a></span>
</div>
</main>
`);
useDom(dom);
const wrapper = dom.window.document.querySelector('.detail')!;
mockVisibleBox(wrapper);

const ancestor = await WebElement.commonAncestor(fakePage(), ['e1', 'e2']);

expect(ancestor?.tag).toBe('div');
expect(ancestor?.filteredClasses).toContain('detail-view-resizable');
});

it('returns null for a single element', async () => {
const dom = new JSDOM(`
<main>
<div class="panel"><button data-explorbot-eidx="e1">Save</button></div>
</main>
`);
useDom(dom);

expect(await WebElement.commonAncestor(fakePage(), ['e1'])).toBeNull();
expect(await WebElement.commonAncestor(fakePage(), ['e1', 'e404'])).toBeNull();
});

it('returns null when elements only share the body', async () => {
const dom = new JSDOM(`
<header><button data-explorbot-eidx="e1">Save</button></header>
<footer><button data-explorbot-eidx="e2">Cancel</button></footer>
`);
useDom(dom);

expect(await WebElement.commonAncestor(fakePage(), ['e1', 'e2'])).toBeNull();
});
});

function fakePage() {
return { evaluate: async (fn: (arg: any) => any, arg: any) => fn(arg) };
}

function restoreGlobal(name: string, descriptor: PropertyDescriptor | undefined) {
if (!descriptor) {
delete (globalThis as any)[name];
return;
}
Object.defineProperty(globalThis, name, descriptor);
}

function useDom(dom: JSDOM) {
(globalThis as any).window = dom.window;
(globalThis as any).document = dom.window.document;
Expand Down