fix(browser): default act timeout budget

Co-authored-by: Andy Lin <andyylin@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-04-25 08:10:54 +01:00
parent 712f7b218c
commit 5376a4a5d6
23 changed files with 283 additions and 11 deletions

View File

@@ -15,6 +15,7 @@ import {
resolveProfile,
wrapExternalContent,
} from "./browser-tool.runtime.js";
import { DEFAULT_BROWSER_ACTION_TIMEOUT_MS } from "./browser/constants.js";
const browserToolActionDeps = {
browserAct,
@@ -25,6 +26,94 @@ const browserToolActionDeps = {
loadConfig,
};
const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000;
type BrowserActRequest = Parameters<typeof browserAct>[1];
type BrowserActRequestWithTimeout = BrowserActRequest & { timeoutMs?: number };
function normalizePositiveTimeoutMs(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
}
function supportsBrowserActTimeout(request: BrowserActRequest): boolean {
switch (request.kind) {
case "click":
case "type":
case "hover":
case "scrollIntoView":
case "drag":
case "select":
case "fill":
case "evaluate":
case "wait":
return true;
default:
return false;
}
}
function existingSessionRejectsActTimeout(request: BrowserActRequest): boolean {
switch (request.kind) {
case "type":
case "hover":
case "scrollIntoView":
case "drag":
case "select":
case "fill":
case "evaluate":
return true;
default:
return false;
}
}
function usesExistingSessionProfile(profileName: string | undefined): boolean {
const cfg = browserToolActionDeps.loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const profile = resolveProfile(resolved, profileName ?? resolved.defaultProfile);
return profile ? getBrowserProfileCapabilities(profile).usesChromeMcp : false;
}
function withConfiguredActTimeout(
request: BrowserActRequest,
profileName: string | undefined,
): BrowserActRequest {
const typedRequest = request as BrowserActRequestWithTimeout;
if (normalizePositiveTimeoutMs(typedRequest.timeoutMs) !== undefined) {
return request;
}
if (!supportsBrowserActTimeout(request)) {
return request;
}
if (existingSessionRejectsActTimeout(request) && usesExistingSessionProfile(profileName)) {
return request;
}
const cfg = browserToolActionDeps.loadConfig();
const configuredTimeout =
normalizePositiveTimeoutMs(cfg.browser?.actionTimeoutMs) ?? DEFAULT_BROWSER_ACTION_TIMEOUT_MS;
return { ...typedRequest, timeoutMs: configuredTimeout } as BrowserActRequest;
}
function resolveActProxyTimeoutMs(request: BrowserActRequest): number | undefined {
const candidateTimeouts: number[] = [];
const explicitTimeout = normalizePositiveTimeoutMs(
(request as BrowserActRequestWithTimeout).timeoutMs,
);
if (explicitTimeout !== undefined) {
candidateTimeouts.push(explicitTimeout + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
}
if (request.kind === "wait") {
const waitDuration = normalizePositiveTimeoutMs(request.timeMs);
if (waitDuration !== undefined) {
candidateTimeouts.push(waitDuration + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
}
}
return candidateTimeouts.length ? Math.max(...candidateTimeouts) : undefined;
}
export const __testing = {
setDepsForTest(
overrides: Partial<{
@@ -408,32 +497,34 @@ export async function executeConsoleAction(params: {
}
export async function executeActAction(params: {
request: Parameters<typeof browserAct>[1];
request: BrowserActRequest;
baseUrl?: string;
profile?: string;
proxyRequest: BrowserProxyRequest | null;
onTabActivity?: (targetId: string | undefined) => void;
}): Promise<AgentToolResult<unknown>> {
const { request, baseUrl, profile, proxyRequest } = params;
const effectiveRequest = withConfiguredActTimeout(request, profile);
try {
const result = proxyRequest
? await proxyRequest({
method: "POST",
path: "/act",
profile,
body: request,
body: effectiveRequest,
timeoutMs: resolveActProxyTimeoutMs(effectiveRequest),
})
: await browserToolActionDeps.browserAct(baseUrl, request, {
: await browserToolActionDeps.browserAct(baseUrl, effectiveRequest, {
profile,
});
params.onTabActivity?.(
readStringValue((result as { targetId?: unknown }).targetId) ??
readStringValue(request.targetId),
readStringValue(effectiveRequest.targetId),
);
return jsonResult(result);
} catch (err) {
if (isChromeStaleTargetError(profile, err)) {
const retryRequest = stripTargetIdFromActRequest(request);
const retryRequest = stripTargetIdFromActRequest(effectiveRequest);
const tabs = proxyRequest
? ((
(await proxyRequest({
@@ -445,7 +536,7 @@ export async function executeActAction(params: {
: await browserToolActionDeps.browserTabs(baseUrl, { profile }).catch(() => []);
// Some user-browser targetIds can go stale between snapshots and actions.
// Only retry safe read-only actions, and only when exactly one tab remains attached.
if (retryRequest && canRetryChromeActWithoutTargetId(request) && tabs.length === 1) {
if (retryRequest && canRetryChromeActWithoutTargetId(effectiveRequest) && tabs.length === 1) {
try {
const retryResult = proxyRequest
? await proxyRequest({
@@ -453,6 +544,7 @@ export async function executeActAction(params: {
path: "/act",
profile,
body: retryRequest,
timeoutMs: resolveActProxyTimeoutMs(retryRequest),
})
: await browserToolActionDeps.browserAct(baseUrl, retryRequest, {
profile,

View File

@@ -69,6 +69,7 @@ const browserConfigMocks = vi.hoisted(() => ({
controlPort: 18791,
profiles: {},
defaultProfile: "openclaw",
actionTimeoutMs: 60_000,
})),
resolveProfile: vi.fn((resolved: Record<string, unknown>, name: string) => {
const profile = (resolved.profiles as Record<string, Record<string, unknown>> | undefined)?.[
@@ -249,6 +250,7 @@ function resetBrowserToolMocks() {
controlPort: 18791,
profiles: {},
defaultProfile: "openclaw",
actionTimeoutMs: 60_000,
});
nodesUtilsMocks.listNodes.mockResolvedValue([]);
browserToolTesting.setDepsForTest({
@@ -292,6 +294,7 @@ function setResolvedBrowserProfiles(
controlPort: 18791,
profiles,
defaultProfile,
actionTimeoutMs: 60_000,
});
}
@@ -1078,6 +1081,87 @@ describe("browser tool act compatibility", () => {
expect.objectContaining({ profile: undefined }),
);
});
it("applies configured browser action timeout when act timeout is omitted", async () => {
configMocks.loadConfig.mockReturnValue({ browser: { actionTimeoutMs: 45_000 } });
const tool = createBrowserTool();
await tool.execute?.("call-1", {
action: "act",
request: {
kind: "wait",
timeMs: 20_000,
},
});
expect(browserActionsMocks.browserAct).toHaveBeenCalledWith(
undefined,
{
kind: "wait",
timeMs: 20_000,
timeoutMs: 45_000,
},
expect.objectContaining({ profile: undefined }),
);
});
it("does not inject unsupported action timeout for existing-session type actions", async () => {
setResolvedBrowserProfiles({
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
configMocks.loadConfig.mockReturnValue({ browser: { actionTimeoutMs: 45_000 } });
const tool = createBrowserTool();
await tool.execute?.("call-1", {
action: "act",
profile: "user",
target: "host",
request: {
kind: "type",
ref: "f1e3",
text: "Test Title",
},
});
expect(browserActionsMocks.browserAct).toHaveBeenCalledWith(
undefined,
{
kind: "type",
ref: "f1e3",
text: "Test Title",
},
expect.objectContaining({ profile: "user" }),
);
});
it("passes configured act timeout through node proxy with transport slack", async () => {
mockSingleBrowserProxyNode();
configMocks.loadConfig.mockReturnValue({
browser: {
actionTimeoutMs: 45_000,
},
gateway: { nodes: { browser: { node: "node-1" } } },
});
const tool = createBrowserTool();
await tool.execute?.("call-1", {
action: "act",
target: "node",
request: { kind: "wait", timeMs: 20_000 },
});
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{ timeoutMs: 55_000 },
expect.objectContaining({
params: expect.objectContaining({
path: "/act",
body: { kind: "wait", timeMs: 20_000, timeoutMs: 45_000 },
timeoutMs: 45_000 + 5_000,
}),
}),
);
});
});
describe("browser tool snapshot labels", () => {

View File

@@ -681,6 +681,7 @@ describe("browser chrome launch args", () => {
evaluateEnabled: false,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
actionTimeoutMs: 60_000,
extraArgs: [],
color: "#FF4500",
headless: false,

View File

@@ -6,7 +6,10 @@ import type {
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import type { BrowserActRequest, BrowserFormField } from "./client-actions.types.js";
import { fetchBrowserJson } from "./client-fetch.js";
import { DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS } from "./constants.js";
import {
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS,
} from "./constants.js";
export type { BrowserActRequest, BrowserFormField } from "./client-actions.types.js";
@@ -26,6 +29,29 @@ export type BrowserDownloadPayload = {
type BrowserDownloadResult = { ok: true; targetId: string; download: BrowserDownloadPayload };
const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000;
function normalizePositiveTimeoutMs(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
}
function resolveBrowserActRequestTimeoutMs(req: BrowserActRequest): number {
const explicitTimeout = normalizePositiveTimeoutMs((req as { timeoutMs?: unknown }).timeoutMs);
const candidateTimeouts =
explicitTimeout === undefined
? [DEFAULT_BROWSER_ACTION_TIMEOUT_MS]
: [explicitTimeout + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS];
if (req.kind === "wait") {
const waitDuration = normalizePositiveTimeoutMs(req.timeMs);
if (waitDuration !== undefined) {
candidateTimeouts.push(waitDuration + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
}
}
return Math.max(...candidateTimeouts);
}
async function postDownloadRequest(
baseUrl: string | undefined,
route: "/wait/download" | "/download",
@@ -167,7 +193,7 @@ export async function browserAct(
timeoutMs:
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
? Math.max(1, Math.floor(opts.timeoutMs))
: 20000,
: resolveBrowserActRequestTimeoutMs(req),
});
}

View File

@@ -334,4 +334,30 @@ describe("browser client", () => {
timeoutMs: 20_000,
});
});
it("gives browser act requests enough client timeout for long waits", async () => {
const calls: Array<{ url: string; init?: RequestInit & { timeoutMs?: number } }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit & { timeoutMs?: number }) => {
calls.push({ url, init });
return {
ok: true,
json: async () => ({ ok: true, targetId: "t1" }),
} as unknown as Response;
}),
);
await browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" });
await browserAct("http://127.0.0.1:18791", {
kind: "wait",
timeMs: 70_000,
});
await browserAct("http://127.0.0.1:18791", {
kind: "wait",
timeoutMs: 45_000,
});
expect(calls.map((call) => call.init?.timeoutMs)).toEqual([60_000, 75_000, 50_000]);
});
});

View File

@@ -60,6 +60,7 @@ describe("browser config", () => {
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
expect(resolved.actionTimeoutMs).toBe(60_000);
expect(resolved.tabCleanup).toEqual({
enabled: true,
idleMinutes: 120,
@@ -119,9 +120,11 @@ describe("browser config", () => {
const resolved = resolveBrowserConfig({
remoteCdpTimeoutMs: 2200,
remoteCdpHandshakeTimeoutMs: 5000,
actionTimeoutMs: 45_000,
});
expect(resolved.remoteCdpTimeoutMs).toBe(2200);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(5000);
expect(resolved.actionTimeoutMs).toBe(45_000);
});
it("supports custom browser tab cleanup policy", () => {

View File

@@ -20,6 +20,7 @@ import { resolveUserPath } from "../utils.js";
import { parseBrowserHttpUrl, redactCdpUrl, isLoopbackHost } from "./cdp.helpers.js";
import {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES,
@@ -66,6 +67,7 @@ export type ResolvedBrowserConfig = {
cdpIsLoopback: boolean;
remoteCdpTimeoutMs: number;
remoteCdpHandshakeTimeoutMs: number;
actionTimeoutMs: number;
color: string;
executablePath?: string;
headless: boolean;
@@ -263,6 +265,10 @@ export function resolveBrowserConfig(
cfg?.remoteCdpHandshakeTimeoutMs,
Math.max(2000, remoteCdpTimeoutMs * 2),
);
const actionTimeoutMs = normalizeTimeoutMs(
cfg?.actionTimeoutMs,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
);
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start;
@@ -343,6 +349,7 @@ export function resolveBrowserConfig(
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
remoteCdpTimeoutMs,
remoteCdpHandshakeTimeoutMs,
actionTimeoutMs,
color: defaultColor,
executablePath,
headless,

View File

@@ -3,6 +3,7 @@ export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
export const DEFAULT_BROWSER_ACTION_TIMEOUT_MS = 60_000;
export const DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS = 20_000;
export const DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES = 120;
export const DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION = 8;

View File

@@ -44,6 +44,7 @@ function makeState(): BrowserServerState {
cdpIsLoopback: true,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
actionTimeoutMs: 60_000,
color: "#FF4500",
headless: false,
noSandbox: false,

View File

@@ -24,6 +24,7 @@ export function makeState(
cdpIsLoopback: profile !== "remote",
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
actionTimeoutMs: 60_000,
evaluateEnabled: false,
extraArgs: [],
color: "#FF4500",

View File

@@ -37,6 +37,7 @@ export function makeBrowserServerState(params?: {
evaluateEnabled: false,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
actionTimeoutMs: 60_000,
extraArgs: [],
color: profile.color,
headless: true,