diff --git a/CHANGELOG.md b/CHANGELOG.md index 20437930ab9..f92ff04c688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -273,6 +273,7 @@ Docs: https://docs.openclaw.ai - Daemon/Windows schtasks runtime detection: use locale-invariant `Last Run Result` running codes (`0x41301`/`267009`) as the primary running signal so `openclaw node status` no longer misreports active tasks as stopped on non-English Windows locales. (#39076) Thanks @ademczuk. - Usage/token count formatting: round near-million token counts to millions (`1.0m`) instead of `1000k`, with explicit boundary coverage for `999_499` and `999_500`. (#39129) Thanks @CurryMessi. - Gateway/session bootstrap cache invalidation ordering: clear bootstrap snapshots only after active embedded-run shutdown wait completes, preventing dying runs from repopulating stale cache between `/new`/`sessions.reset` turns. (#38873) Thanks @MumuTW. +- Browser/dispatcher error clarity: preserve dispatcher-side failure context in browser fetch errors while still appending operator guidance and explicit no-retry model hints, preventing misleading `"Can't reach service"` wrapping and avoiding LLM retry loops. (#39090) Thanks @NewdlDewdl. ## 2026.3.2 diff --git a/src/browser/client-fetch.loopback-auth.test.ts b/src/browser/client-fetch.loopback-auth.test.ts index 3dc17e72730..70042a2ebee 100644 --- a/src/browser/client-fetch.loopback-auth.test.ts +++ b/src/browser/client-fetch.loopback-auth.test.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ }, }, })), + startBrowserControlServiceFromConfig: vi.fn(async () => ({ ok: true })), + dispatch: vi.fn(async () => ({ status: 200, body: { ok: true } })), })); vi.mock("../config/config.js", async (importOriginal) => { @@ -20,12 +22,12 @@ vi.mock("../config/config.js", async (importOriginal) => { vi.mock("./control-service.js", () => ({ createBrowserControlContext: vi.fn(() => ({})), - startBrowserControlServiceFromConfig: vi.fn(async () => ({ ok: true })), + startBrowserControlServiceFromConfig: mocks.startBrowserControlServiceFromConfig, })); vi.mock("./routes/dispatcher.js", () => ({ createBrowserRouteDispatcher: vi.fn(() => ({ - dispatch: vi.fn(async () => ({ status: 200, body: { ok: true } })), + dispatch: mocks.dispatch, })), })); @@ -54,6 +56,8 @@ describe("fetchBrowserJson loopback auth", () => { }, }, }); + mocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue({ ok: true }); + mocks.dispatch.mockReset().mockResolvedValue({ status: 200, body: { ok: true } }); }); afterEach(() => { @@ -114,4 +118,32 @@ describe("fetchBrowserJson loopback auth", () => { const headers = new Headers(init?.headers); expect(headers.get("authorization")).toBe("Bearer loopback-token"); }); + + it("preserves dispatcher error context while keeping no-retry hint", async () => { + mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout")); + + const thrown = await fetchBrowserJson<{ ok: boolean }>("/tabs").catch((err) => err as Error); + + expect(thrown).toBeInstanceOf(Error); + expect(thrown.message).toContain("Chrome CDP handshake timeout"); + expect(thrown.message).toContain("Do NOT retry the browser tool"); + expect(thrown.message).not.toContain("Can't reach the OpenClaw browser control service"); + }); + + it("keeps absolute URL failures wrapped as reachability errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("socket hang up"); + }), + ); + + const thrown = await fetchBrowserJson<{ ok: boolean }>("http://example.com/").catch( + (err) => err as Error, + ); + + expect(thrown).toBeInstanceOf(Error); + expect(thrown.message).toContain("Can't reach the OpenClaw browser control service"); + expect(thrown.message).toContain("Do NOT retry the browser tool"); + }); }); diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index 9f9f6daf07d..8f13da4e1aa 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -98,17 +98,40 @@ function withLoopbackBrowserAuth( }); } -function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error { +const BROWSER_TOOL_MODEL_HINT = + "Do NOT retry the browser tool — it will keep failing. " + + "Use an alternative approach or inform the user that the browser is currently unavailable."; + +function resolveBrowserFetchOperatorHint(url: string): string { const isLocal = !isAbsoluteHttp(url); - // Human-facing hint for logs/diagnostics. - const operatorHint = isLocal + return isLocal ? `Restart the OpenClaw gateway (OpenClaw.app menubar, or \`${formatCliCommand("openclaw gateway")}\`).` : "If this is a sandboxed session, ensure the sandbox browser is running."; - // Model-facing suffix: explicitly tell the LLM NOT to retry. - // Without this, models see "try again" and enter an infinite tool-call loop. - const modelHint = - "Do NOT retry the browser tool — it will keep failing. " + - "Use an alternative approach or inform the user that the browser is currently unavailable."; +} + +function normalizeErrorMessage(err: unknown): string { + if (err instanceof Error && err.message.trim().length > 0) { + return err.message.trim(); + } + return String(err); +} + +function appendBrowserToolModelHint(message: string): string { + if (message.includes(BROWSER_TOOL_MODEL_HINT)) { + return message; + } + return `${message} ${BROWSER_TOOL_MODEL_HINT}`; +} + +function enhanceDispatcherPathError(url: string, err: unknown): Error { + const msg = normalizeErrorMessage(err); + const suffix = `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}`; + const normalized = msg.endsWith(".") ? msg : `${msg}.`; + return new Error(`${normalized} ${suffix}`, err instanceof Error ? { cause: err } : undefined); +} + +function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error { + const operatorHint = resolveBrowserFetchOperatorHint(url); const msg = String(err); const msgLower = msg.toLowerCase(); const looksLikeTimeout = @@ -119,11 +142,15 @@ function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): msgLower.includes("aborterror"); if (looksLikeTimeout) { return new Error( - `Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint} ${modelHint}`, + appendBrowserToolModelHint( + `Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint}`, + ), ); } return new Error( - `Can't reach the OpenClaw browser control service. ${operatorHint} ${modelHint} (${msg})`, + appendBrowserToolModelHint( + `Can't reach the OpenClaw browser control service. ${operatorHint} (${msg})`, + ), ); } @@ -165,11 +192,13 @@ export async function fetchBrowserJson( init?: RequestInit & { timeoutMs?: number }, ): Promise { const timeoutMs = init?.timeoutMs ?? 5000; + let isDispatcherPath = false; try { if (isAbsoluteHttp(url)) { const httpInit = withLoopbackBrowserAuth(url, init); return await fetchHttpJson(url, { ...httpInit, timeoutMs }); } + isDispatcherPath = true; const started = await startBrowserControlServiceFromConfig(); if (!started) { throw new Error("browser control disabled"); @@ -251,6 +280,11 @@ export async function fetchBrowserJson( if (err instanceof BrowserServiceError) { throw err; } + // Dispatcher-path failures are service-operation failures, not network + // reachability failures. Keep the original context, but retain anti-retry hints. + if (isDispatcherPath) { + throw enhanceDispatcherPathError(url, err); + } throw enhanceBrowserFetchError(url, err, timeoutMs); } }