Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(browser): Centralize and improve error handling in browser commands #38

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@
"@browserbasehq/stagehand": "1.12.0",
"dotenv": "16.4.7",
"eventsource-client": "1.1.3",
"groq-sdk": "^0.15.0",
"punycode": "^2.3.1",
"repomix": "0.2.24",
"zod": "3.24.1"
"zod": "3.24.1",
"zod-to-json-schema": "^3.24.1"
},
"peerDependencies": {
"playwright": "1.50.1"
Expand Down
21 changes: 21 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

195 changes: 68 additions & 127 deletions src/commands/browser/stagehand/act.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,15 @@
import type { Command, CommandGenerator } from '../../../types';
import { formatOutput, handleBrowserError, ActionError, NavigationError } from './stagehandUtils';
import {
BrowserResult,
ConstructorParams,
InitResult,
LogLine,
Stagehand,
} from '@browserbasehq/stagehand';
import { loadConfig } from '../../../config';
import {
loadStagehandConfig,
validateStagehandConfig,
getStagehandApiKey,
getStagehandModel,
} from './config';
import { createStagehand, navigateToUrl, DEFAULT_TIMEOUTS } from './createStagehand';
import { ActionError } from './errors';
import { Stagehand } from '@browserbasehq/stagehand';
import type { SharedBrowserCommandOptions } from '../browserOptions';
import {
setupConsoleLogging,
setupNetworkMonitoring,
captureScreenshot,
outputMessages,
setupVideoRecording,
} from '../utilsShared';
import { overrideStagehandInit, stagehandLogger } from './initOverride';

export type RecordVideoOptions = {
/**, stagehandLogger
* Path to the directory to put videos into.
*/
dir: string;
};

overrideStagehandInit();
import { formatOutput, handleBrowserError } from './stagehandUtils';

export class ActCommand implements Command {
async *execute(query: string, options?: SharedBrowserCommandOptions): CommandGenerator {
Expand All @@ -46,116 +24,37 @@ export class ActCommand implements Command {
return;
}

// Load and validate configuration
const config = loadConfig();
const stagehandConfig = loadStagehandConfig(config);
validateStagehandConfig(stagehandConfig);

let stagehand: Stagehand | undefined;
let consoleMessages: string[] = [];
let networkMessages: string[] = [];

let stagehandInstance;
try {
const config = {
env: 'LOCAL',
headless: options?.headless ?? stagehandConfig.headless,
verbose: options?.debug || stagehandConfig.verbose ? 1 : 0,
debugDom: options?.debug ?? stagehandConfig.debugDom,
modelName: getStagehandModel(stagehandConfig, { model: options?.model }),
apiKey: getStagehandApiKey(stagehandConfig),
enableCaching: stagehandConfig.enableCaching,
logger: stagehandLogger(options?.debug ?? stagehandConfig.verbose),
} satisfies ConstructorParams;

// Set default values for network and console options
options = {
...options,
network: options?.network === undefined ? true : options.network,
console: options?.console === undefined ? true : options.console,
};
stagehandInstance = await createStagehand(options);
const { stagehand, page } = stagehandInstance;

console.log('using stagehand config', { ...config, apiKey: 'REDACTED' });
stagehand = new Stagehand(config);

await using _stagehand = {
[Symbol.asyncDispose]: async () => {
console.error('closing stagehand, this can take a while');
await Promise.race([
options?.connectTo ? undefined : stagehand?.page.close(),
stagehand?.close(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Page close timeout')), 5000)
),
]);
console.error('stagehand closed');
},
};
const consoleMessages = await setupConsoleLogging(page, options || {});
const networkMessages = await setupNetworkMonitoring(page, options || {});

// Initialize with timeout
const initPromise = stagehand.init({
// this method is overriden in our Stagehand class patch hack
...options,
//@ts-ignore
recordVideo: options.video
? {
dir: await setupVideoRecording(options),
}
: undefined,
});
const initTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Initialization timeout')), 30000)
);
await Promise.race([initPromise, initTimeoutPromise]);

// Setup console and network monitoring
consoleMessages = await setupConsoleLogging(stagehand.page, options || {});
networkMessages = await setupNetworkMonitoring(stagehand.page, options || {});

try {
// Skip navigation if url is 'current' or if current URL matches target URL
if (url !== 'current') {
const currentUrl = await stagehand.page.url();
if (currentUrl !== url) {
// Navigate with timeout
const gotoPromise = stagehand.page.goto(url);
const gotoTimeoutPromise = new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Navigation timeout')),
stagehandConfig.timeout ?? 30000
)
);
await Promise.race([gotoPromise, gotoTimeoutPromise]);
} else {
console.log('Skipping navigation - already on correct page');
}
} else {
console.log('Skipping navigation - using current page');
}
} catch (error) {
throw new NavigationError(
`Failed to navigate to ${url}. Please check if the URL is correct and accessible.`,
error
);
await navigateToUrl(stagehand, url, options?.timeout);

// Execute custom JavaScript if provided
if (options?.evaluate) {
await page.evaluate(options.evaluate);
}

const result = await this.performAction(
stagehand,
{ instruction: query, evaluate: options?.evaluate },
options?.timeout ?? stagehandConfig.timeout
options?.timeout,
options
);

// Take screenshot if requested
await captureScreenshot(stagehand.page, options || {});
await captureScreenshot(page, options || {});

// Output result and messages
yield formatOutput(result, options?.debug);
for (const message of outputMessages(consoleMessages, networkMessages, options || {})) {
yield message;
}

// Output HTML content if requested
if (options?.html) {
const htmlContent = await stagehand.page.content();
const htmlContent = await page.content();
yield '\n--- Page HTML Content ---\n\n';
yield htmlContent;
yield '\n--- End of HTML Content ---\n';
Expand All @@ -165,8 +64,11 @@ export class ActCommand implements Command {
yield `Screenshot saved to ${options.screenshot}\n`;
}
} catch (error) {
console.log('error in stagehand loop');
yield 'error in stagehand: ' + handleBrowserError(error, options?.debug);
} finally {
if (stagehandInstance) {
await stagehandInstance.cleanup();
}
}
}

Expand All @@ -179,8 +81,15 @@ export class ActCommand implements Command {
instruction: string;
evaluate?: string;
},
timeout = 120000
): Promise<string> {
timeout: number = DEFAULT_TIMEOUTS.ACTION,
options?: SharedBrowserCommandOptions
): Promise<{
success: boolean;
message: string;
startUrl: string;
endUrl: string;
instruction: string;
}> {
try {
// Get the current URL before the action
const startUrl = await stagehand.page.url();
Expand All @@ -198,16 +107,30 @@ export class ActCommand implements Command {
for (const instruct of instruction.split('|')) {
let stepTimeout: ReturnType<typeof setTimeout> | undefined;
const stepTimeoutPromise = new Promise((_, reject) => {
stepTimeout = setTimeout(() => reject(new Error('step timeout')), 90000);
stepTimeout = setTimeout(
() => reject(new Error('step timeout')),
DEFAULT_TIMEOUTS.ACTION_STEP
);
});

// Execute the action and wait for it to complete
await Promise.race([stagehand.page.act(instruct), totalTimeoutPromise, stepTimeoutPromise]);
if (stepTimeout !== undefined) {
clearTimeout(stepTimeout);
}

// Wait for the DOM to be ready after the action
await Promise.race([
stagehand.page.waitForLoadState('domcontentloaded'),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('DOM load timeout')), DEFAULT_TIMEOUTS.ACTION_STEP)
),
]);

console.log('step done', instruct);
}

// Wait for potential navigation
// Wait for potential navigation or dynamic content to settle
await new Promise((resolve) => setTimeout(resolve, 1000));

// Get the current URL after the action
Expand All @@ -219,16 +142,34 @@ export class ActCommand implements Command {

// If the URL changed, consider the action successful
if (endUrl !== startUrl) {
return `Successfully performed action: ${instruction} (final url ${endUrl})`;
return {
success: true,
message: `Successfully performed action: ${instruction} (final url ${endUrl})`,
startUrl,
endUrl,
instruction,
};
}

return `Successfully performed action: ${instruction}`;
return {
success: true,
message: `Successfully performed action: ${instruction}`,
startUrl,
endUrl,
instruction,
};
} catch (error) {
console.log('error in stagehand step', error);
// Log detailed error information in debug mode
if (options?.debug) {
console.error('Error in performAction:', error);
}

if (error instanceof Error) {
throw new ActionError(`${error.message} Failed to perform action: ${instruction}`, {
instruction,
error,
startUrl: await stagehand.page.url(),
pageContent: await stagehand.page.content(),
availableElements: await stagehand.page.$$eval(
'a, button, [role="button"], input, select, textarea',
(elements: Element[]) =>
Expand Down
Loading