Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
## 2026-07-17

### Changes
- Browser crash recovery now applies to every browser operation. Previously some interactions and page lookups could fail permanently when the page or browser crashed mid-session; now every action, capture, and element query automatically reattaches the page or restarts the browser and retries once before giving up.
- [Captain] The `browser` tool's `restart` action was merged into `recover` — recovering a page now escalates to a full browser restart automatically when needed, so there is no separate action to choose.
- Failed page actions that accidentally entered an iframe now return to the main page automatically. Previously only failed form actions did this; now it applies to every action, so a failed click or keypress can no longer leave the session stuck inside an iframe.
- [Pilot] Pilot's instructions and its list of available tools now stay identical across every call in a session, with the scenario details moved to the end. Providers can reuse the prompt they already processed instead of re-reading it on each call, which lowers the cost of long test runs.
- [Planner] Planning rules and output format instructions now come before the page research, so planning more tests for the same page reuses an already-processed prompt.
- When a click matches several elements, the follow-up question that picks the right one now uses the default model instead of the more expensive agentic model.
- 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.

## 2026-07-10
Expand Down
4 changes: 2 additions & 2 deletions bin/explorbot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,11 +720,11 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
await explorBot.start();
await explorBot.agentNavigator().visit(url);

const action = explorBot.getExplorer().createAction();
const action = explorBot.getExplorer().action();
await action.execute(command);

log('Command executed successfully');
const state = explorBot.getExplorer().getStateManager().getCurrentState();
const state = explorBot.stateManager().getCurrentState();
if (state) log(`URL: ${state.url}`);

await explorBot.stop();
Expand Down
7 changes: 5 additions & 2 deletions boat/doc-collector/src/ai/documentarian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import dedent from 'dedent';
import { z } from 'zod';
import type { AIProvider } from '../../../../src/ai/provider.ts';
import type Explorer from '../../../../src/explorer.ts';
import type { StateManager } from '../../../../src/state-manager.ts';
import type { WebPageState } from '../../../../src/state-manager.ts';
import { tag } from '../../../../src/utils/logger.ts';
import type { DocbotConfig } from '../config.ts';
Expand All @@ -11,11 +12,13 @@ class Documentarian {
private provider: AIProvider;
private config: DocbotConfig;
private explorer?: Explorer;
private stateManager?: StateManager;

constructor(provider: AIProvider, config: DocbotConfig = {}, explorer?: Explorer) {
constructor(provider: AIProvider, config: DocbotConfig = {}, explorer?: Explorer, stateManager?: StateManager) {
this.provider = provider;
this.config = config;
this.explorer = explorer;
this.stateManager = stateManager;
}

async document(state: WebPageState, research: string, captureState?: CaptureInteractionState): Promise<PageDocumentation> {
Expand Down Expand Up @@ -46,7 +49,7 @@ class Documentarian {
try {
tag('info').log('Starting interactive exploration...');

const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research, this.config, captureState);
const deterministicInteractions = await collectDocInteractions(this.explorer!, this.stateManager!, state, research, this.config, captureState);
meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
if (meaningfulInteractions.length > 0) {
tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
Expand Down
23 changes: 12 additions & 11 deletions boat/doc-collector/src/ai/tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type ResearchElement, parseResearchSections } from '../../../../src/ai/researcher/parser.ts';
import type Explorer from '../../../../src/explorer.ts';
import type { WebPageState } from '../../../../src/state-manager.ts';
import type { StateManager, WebPageState } from '../../../../src/state-manager.ts';
import { detectFocusArea } from '../../../../src/utils/aria.ts';
import type { DocbotConfig } from '../config.ts';

Expand Down Expand Up @@ -58,22 +58,22 @@ const DEFAULT_WAIT_MS = 700;
const TAB_WAIT_MS = 500;
const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'];

export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string, config: DocbotConfig = {}, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
export async function collectDocInteractions(explorer: Explorer, stateManager: StateManager, state: WebPageState, research: string, config: DocbotConfig = {}, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
const sections = parseResearchSections(research);
const transitions: DocStateTransition[] = [];
const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
const tabGroup = findTabGroup(sections);

if (tabGroup) {
transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions, captureState)));
transitions.push(...(await exploreTabGroup(explorer, stateManager, tabGroup, state.url, maxInteractions, captureState)));
}

for (const candidate of findActionCandidates(sections, config)) {
if (transitions.length >= maxInteractions) {
break;
}

const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS, captureState);
const transition = await executeInteraction(explorer, stateManager, candidate, state.url, DEFAULT_WAIT_MS, captureState);
if (!transition) {
continue;
}
Expand All @@ -92,7 +92,7 @@ export function pickDocActionCandidates(research: string, config: DocbotConfig =
}));
}

async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
async function exploreTabGroup(explorer: Explorer, stateManager: StateManager, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
const transitions: DocStateTransition[] = [];

for (const element of tabGroup.elements) {
Expand All @@ -102,6 +102,7 @@ async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: Researc

const transition = await executeInteraction(
explorer,
stateManager,
{
element,
container: tabGroup.container,
Expand All @@ -123,8 +124,8 @@ async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: Researc
return transitions;
}

async function executeInteraction(explorer: Explorer, candidate: InteractionCandidate, restoreUrl: string, waitMs: number, captureState?: CaptureInteractionState): Promise<DocStateTransition | null> {
const beforeState = explorer.getStateManager().getCurrentState();
async function executeInteraction(explorer: Explorer, stateManager: StateManager, candidate: InteractionCandidate, restoreUrl: string, waitMs: number, captureState?: CaptureInteractionState): Promise<DocStateTransition | null> {
const beforeState = stateManager.getCurrentState();
if (!beforeState) {
return null;
}
Expand All @@ -136,7 +137,7 @@ async function executeInteraction(explorer: Explorer, candidate: InteractionCand

await wait(waitMs);

const afterState = explorer.getStateManager().getCurrentState();
const afterState = stateManager.getCurrentState();
if (!afterState) {
return null;
}
Expand Down Expand Up @@ -164,7 +165,7 @@ async function executeInteraction(explorer: Explorer, candidate: InteractionCand
}

async function attemptInteraction(explorer: Explorer, candidate: InteractionCandidate): Promise<boolean> {
const action = explorer.createAction();
const action = explorer.action();

for (const command of buildClickCommands(candidate.element, candidate.container)) {
const success = await action.attempt(command, buildPurpose(candidate));
Expand All @@ -178,15 +179,15 @@ async function attemptInteraction(explorer: Explorer, candidate: InteractionCand

async function restoreInteractionState(explorer: Explorer, restoreUrl: string, primaryCommand?: string | null): Promise<void> {
if (primaryCommand) {
const action = explorer.createAction();
const action = explorer.action();
const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`);
if (restored) {
await wait(TAB_WAIT_MS);
return;
}
}

const action = explorer.createAction();
const action = explorer.action();
await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`);
}

Expand Down
6 changes: 3 additions & 3 deletions boat/doc-collector/src/docbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import { sanitizeFilename } from '../../../src/utils/strings.ts';
import { Documentarian, type PageDocumentation } from './ai/documentarian.ts';
import { type DocbotConfig, DocbotConfigParser } from './config.ts';
import { type DocumentedPage, type SkippedPage, renderPageDocumentation, renderSpecIndex } from './docs-renderer.ts';
import { renderMermaidBody } from './state-diagram.ts';
import { getDocPageKey, shouldCrawlDocPath } from './path-filter.ts';
import { extractResearchNavigationTargets } from './research-navigation.ts';
import { type DocumentationScreenshot, captureDocumentationScreenshots, captureInteractionScreenshot } from './screenshots.ts';
import { renderMermaidBody } from './state-diagram.ts';

class DocBot {
private explorBot: ExplorBot;
Expand Down Expand Up @@ -43,7 +43,7 @@ class DocBot {
config: this.options.docsConfig,
path: this.options.path,
});
this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer());
this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer(), this.explorBot.stateManager());
this.ensureDirectory(this.configParser.getOutputDir());
this.ensureDirectory(this.getPagesDir());
}
Expand Down Expand Up @@ -76,7 +76,7 @@ class DocBot {
continue;
}

const stateManager = this.explorBot.getExplorer().getStateManager();
const stateManager = this.explorBot.stateManager();
if (stateManager.hasVisitedState(target)) {
continue;
}
Expand Down
4 changes: 2 additions & 2 deletions boat/doc-collector/src/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { DocbotConfig } from './config.ts';
const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;

export async function captureDocumentationScreenshots(explorer: Explorer, state: WebPageState, research: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot[]> {
const page = explorer.playwrightHelper?.page;
const page = explorer.page;
if (!page) {
return [];
}
Expand Down Expand Up @@ -62,7 +62,7 @@ export function getScreenshotSections(research: string): ScreenshotSection[] {
}

export async function captureInteractionScreenshot(explorer: Explorer, state: WebPageState, transition: DocStateTransition, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
const page = explorer.playwrightHelper?.page;
const page = explorer.page;
if (!page) {
return null;
}
Expand Down
30 changes: 25 additions & 5 deletions src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError }
import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
import { safeFilename } from './utils/strings.ts';
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';

const debugLog = createDebug('explorbot:action');
const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
Expand All @@ -33,14 +33,16 @@ class Action {
public playwrightGroupId: string | null = null;
public assertionSteps: Array<{ name: string; args: any[] }> = [];
private recorder?: PlaywrightRecorder;
private recovery: RecoveryRunner;
private mainDocumentStatus: number | undefined = undefined;

constructor(actor: CodeceptJS.I, stateManager: StateManager, recorder?: PlaywrightRecorder) {
constructor(actor: CodeceptJS.I, stateManager: StateManager, recorder?: PlaywrightRecorder, recovery?: RecoveryRunner) {
this.actor = actor;
this.stateManager = stateManager;
this.config = ConfigParser.getInstance().getConfig();
this.playwrightHelper = container.helpers('Playwright');
this.recorder = recorder;
this.recovery = recovery || ((fn) => fn());
}

async saveScreenshot(): Promise<string | undefined> {
Expand All @@ -59,7 +61,15 @@ class Action {
}
}

async capturePageState({ includeScreenshot = false, codeBlock }: { includeScreenshot?: boolean; codeBlock?: string } = {}): Promise<ActionResult> {
async capturePageState(opts: { includeScreenshot?: boolean; codeBlock?: string } = {}): Promise<ActionResult> {
return this.recovery(() => this.captureOnce(opts));
}

async execute(code: string): Promise<Action> {
return this.recovery(() => this.executeOnce(code));
}

private async captureOnce({ includeScreenshot = false, codeBlock }: { includeScreenshot?: boolean; codeBlock?: string } = {}): Promise<ActionResult> {
try {
const currentState = this.stateManager.getCurrentState();
const stateHash = currentState?.hash || 'screenshot';
Expand Down Expand Up @@ -260,7 +270,7 @@ class Action {
}
}

async execute(code: string): Promise<Action> {
private async executeOnce(code: string): Promise<Action> {
let error: Error | null = null;

setActivity('🔎 Browsing...', 'action');
Expand Down Expand Up @@ -301,7 +311,7 @@ class Action {
codeString = executedSteps.join('\n');
}

const pageState = await this.capturePageState({ codeBlock: codeString });
const pageState = await this.captureOnce({ codeBlock: codeString });

this.actionResult = pageState;
this.assertionSteps = assertionSteps;
Expand Down Expand Up @@ -329,6 +339,7 @@ class Action {
}

public async attempt(codeBlock: string, originalMessage?: string): Promise<boolean> {
const wasInFrame = !!this.playwrightHelper.frame;
try {
debugLog('Resolution attempt...');
setActivity('🦾 Acting in browser...', 'action');
Expand All @@ -343,11 +354,18 @@ class Action {
} catch (error) {
this.lastError = error as Error;
if (isFatalBrowserError(error)) throw error;
if (!wasInFrame) await this.exitIframe().catch(() => {});
debugLog(`Attempt failed: ${codeBlock}: ${errorToString(error) || this.lastError?.toString()}`);
return false;
}
}

async exitIframe(): Promise<void> {
if (!this.playwrightHelper.frame) return;
debugLog('Switching to main frame');
await this.playwrightHelper.switchTo();
}

getActor(): CodeceptJS.I {
return this.actor;
}
Expand All @@ -366,6 +384,8 @@ class Action {

export default Action;

export type RecoveryRunner = <T>(fn: () => Promise<T>) => Promise<T>;

function errorToString(error: any): string {
if (error.cliMessage) {
return error.cliMessage();
Expand Down
20 changes: 20 additions & 0 deletions src/ai/agent.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
import type { RequestStore } from '../api/request-store.ts';
import type { ExplorbotConfig } from '../config.ts';
import type Explorer from '../explorer.ts';
import type { KnowledgeTracker } from '../knowledge-tracker.ts';
import type { PlaywrightRecorder } from '../playwright-recorder.ts';
import type { StateManager } from '../state-manager.ts';
import type { AIProvider } from './provider.ts';

export interface Agent {
emoji?: string;
}

export interface AgentDeps {
explorer: Explorer;
ai: AIProvider;
config: ExplorbotConfig;
stateManager: StateManager;
knowledgeTracker: KnowledgeTracker;
requestStore: RequestStore;
playwrightRecorder: PlaywrightRecorder;
}

export type ToolDeps = Pick<AgentDeps, 'explorer' | 'stateManager' | 'ai'>;
17 changes: 8 additions & 9 deletions src/ai/captain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class Captain extends CaptainBase implements Agent {
private getHooksRunner(): HooksRunner {
if (!this.hooksRunner) {
const explorer = this.explorBot.getExplorer();
this.hooksRunner = new HooksRunner(explorer, explorer.getConfig());
this.hooksRunner = new HooksRunner(explorer, this.explorBot.getConfig());
}
return this.hooksRunner;
}
Expand Down Expand Up @@ -89,17 +89,16 @@ export class Captain extends CaptainBase implements Agent {
getMode(): CaptainMode {
const explorer = this.explorBot.getExplorer();
const activeTest = explorer.activeTest;
const page = explorer.playwrightHelper?.page;

if (activeTest && (!page || page.isClosed?.())) return 'heal';
if (activeTest && !explorer.page) return 'heal';
if (activeTest) return 'test';
if (explorer.getStateManager().getCurrentState()) return 'web';
if (this.explorBot.stateManager().getCurrentState()) return 'web';
return 'idle';
}

private systemPrompt(): string {
const mode = this.getMode();
const currentUrl = this.explorBot.getExplorer().getStateManager().getCurrentState()?.url;
const currentUrl = this.explorBot.stateManager().getCurrentState()?.url;
const customPrompt = this.explorBot.getProvider().getSystemPromptForAgent('captain', currentUrl);

return dedent`
Expand Down Expand Up @@ -147,7 +146,7 @@ export class Captain extends CaptainBase implements Agent {
}

private async getPageContext(): Promise<string> {
const state = this.explorBot.getExplorer().getStateManager().getCurrentState();
const state = this.explorBot.stateManager().getCurrentState();
if (!state) {
return 'No page loaded';
}
Expand Down Expand Up @@ -339,7 +338,7 @@ export class Captain extends CaptainBase implements Agent {
}

const pilotAnalysis = this.explorBot.agentPilot().getLastAnalysis() || '';
const currentUrl = this.explorBot.getExplorer().getStateManager().getCurrentState()?.url || '';
const currentUrl = this.explorBot.stateManager().getCurrentState()?.url || '';

const schema = z.object({
action: z.enum(['inject', 'stop', 'pass', 'skip']),
Expand Down Expand Up @@ -390,7 +389,7 @@ export class Captain extends CaptainBase implements Agent {

async processExecutionError(error: Error, activeTest: Test): Promise<ExecutionRecoveryAction> {
const explorer = this.explorBot.getExplorer();
const result = await explorer.handleExecutionError(error);
const result = await explorer.recover(error);
return {
...result,
message: result.recovered ? `${result.message}\nContinue the test "${activeTest.scenario}" from the restored page.` : result.message,
Expand All @@ -402,7 +401,7 @@ export class Captain extends CaptainBase implements Agent {
}

async handle(input: string, options: { reset?: boolean } = {}): Promise<string | null> {
const stateManager = this.explorBot.getExplorer().getStateManager();
const stateManager = this.explorBot.stateManager();
const initialState = stateManager.getCurrentState();

const conversation = options.reset ? this.resetConversation() : this.ensureConversation();
Expand Down
Loading
Loading