Skip to content

BrowserDisconnectedError is reported as "You must use exactly one tool", so handleBrowserDisconnect() never runs #639

Description

@lmorchard

Summary

When a tool's execute() throws, the AI SDK records the error as a tool result rather than propagating the throw, and aiResponse.toolResults comes back empty. webAgent.ts reads empty toolResults as "the model called no tool" and throws ToolExecutionError("You must use exactly one tool."), discarding the original error's type.

For BrowserDisconnectedError this is expensive: it is exactly the error handleBrowserDisconnect() exists to recover from, and the instanceof check that would trigger recovery can never match. Pilo has complete, correct reconnect logic that is unreachable via this path.

Impact

Measured on published hundred.jsonl eval runs, build 7d0f381688f5394892eead1fe28fe4fd7ebf6a9f:

  • 19 of 52 task failures (37%) — the single largest failure mode on that build.
  • browser:reconnected fired 0 times across all 19, despite a real BrowserDisconnectedError in every one.
  • Pass rate falls from 81% (159 attempts with no such event) to 8% (12 attempts where it happened 3×).
toolCallMalformed count n aborts claiming disconnect pass rate
0 159 0 (0%) 81%
1 8 12% 50%
2 8 62% 38%
3 12 92% 8%

Concentrated on Coursera (7 failures) and Cambridge Dictionary (5) — enough to account for Coursera's entire deficit against browser-use in a 3-way comparison, where browser-use passes the same tasks on the same Bright Data endpoint (verified: the CDP secret keys are byte-identical, so this is not a provider difference).

The disconnects themselves are upstream — ~11% of attempts on Bright Data vs 0/930 on browserless with the same agent build. But the failure to recover is pilo's, and the recovery is already written.

Root cause

  1. The browser layer throws BrowserDisconnectedErrorpackages/core/src/browser/playwrightBrowser.ts:729.

  2. BrowserDisconnectedError extends RecoverableError, not BrowserException (packages/core/src/errors.ts:184), so webActionTools.ts:255's instanceof BrowserException catch correctly does not swallow it and line 287 re-throws. This part works as intended.

  3. But the throw happens inside the AI SDK's tool execution, beneath streamText. The SDK catches it, records a tool result with output.type === 'error-text', and resolves normally — with toolResults empty. From the artifacts:

{
  "type": "tool-result",
  "toolName": "enter",
  "output": {
    "type": "error-text",
    "value": "BrowserDisconnectedError: Browser connection lost: locator.press: Target page, context or browser has been closed\nBrowser logs:\n\ninternal server error (brob)"
  }
}
  1. packages/core/src/webAgent.ts:1296-1300 then treats that as a no-tool-call:
// Process tool results
if (!aiResponse?.toolResults?.length) {
  console.error("[WebAgent] No tools called in action generation");
  throw new ToolExecutionError(
    "You must use exactly one tool. Please use one of the available tools.",

This is the defect. toolResults.length === 0 conflates "the model called no tool" with "the tool the model called threw."

  1. So the dispatch at webAgent.ts:738 can never match for this class:
if (error instanceof BrowserDisconnectedError) {
  await this.handleBrowserDisconnect(task, error, executionState);
  1. addErrorFeedback() (webAgent.ts:889) then early-returns for ToolExecutionError:

IMPORTANT: Tool execution errors (ToolExecutionError) are NOT added as user messages because the error information is already present in the tool result output.

Sound in general, but false for the branch at line 1297 — which fires precisely because there is no tool result carrying that information.

  1. The model does see the error-text in its message history, correctly concludes the browser is dead, and after 3–4 such turns aborts saying so. The agent's abort narration is accurate — it is the only component diagnosing this correctly. (Worth noting because downstream eval tooling had been classifying these as agent confabulation.)

Relationship to #637

#637 added system:debug_no_tool_call to separate prose-instead-of-tool from truncation. It records a third case neither hypothesis covers:

{'iterationId': 'pvgwucsK', 'finishReason': 'tool-calls', 'textLength': '0', 'textPreview': ''}

finishReason: 'tool-calls' with zero text — the model did emit a tool call. It was executed, and it threw. That diagnostic is what made this findable.

Suggested fix

In the !aiResponse?.toolResults?.length branch, distinguish the two cases before throwing: inspect the returned response.messages for a tool-result part with output.type === 'error-text' and re-throw the original typed error (minimally, throw BrowserDisconnectedError when the value matches) so the existing instanceof dispatch works. Fall back to "You must use exactly one tool" only when there genuinely was no tool call.

A tidier variant: have the tool wrappers use the SDK's typed tool-error channel so pilo never infers type from a string. Larger change; the narrow fix restores recovery on its own.

Separately worth deciding: whether addErrorFeedback should push a user message for the true no-tool-call case, since today that path gives the model no feedback at all.

What still needs confirming

Step 3 is inferred from artifacts, not read out of the SDK. The evidence is strong — the error-text tool result, finishReason: 'tool-calls' with empty text, and the ToolExecutionError firing — but nobody has confirmed against ai@^7.0.16 that toolResults excludes errored calls in all cases, or checked whether the SDK exposes a typed error channel that would make the fix cleaner. Please verify before settling on an approach.

Also unknown: whether the same swallowing hides other typed errors with their own recovery paths. BrowserDisconnectedError is the one with a measured cost.

Reproduction

Not yet run. Proposed:

  1. Point pilo at a CDP endpoint you can kill (local chromedp/headless-shell or browserless), PILO_REQUIRE_CDP=true.
  2. Start a multi-step task; once it's interacting with the page, kill the browser container.
  3. Current: tool:execution:error = "You must use exactly one tool", no browser:reconnected, agent aborts within ~4 iterations blaming the browser. Fixed: browser:reconnected emitted, task restarts on the next endpoint.

A unit test is the better regression guard — stub the AI call to return toolResults: [] plus a response.messages entry containing an error-text output whose value is a BrowserDisconnectedError message, and assert handleBrowserDisconnect() is invoked. packages/core/test/webAgent.test.ts already stubs generation this way.


Source claims cited against mozilla/pilo@7446077e. Evidence: 19 task artifacts from scheduled-bu-pilo-tab-hundred runs 1785337200 / 1785423600, participant pilo-cli — public, no auth: https://storage.googleapis.com/pilo-public-eval-reports/reports/<run>/<task-id>/result.json. Full analysis: Mozilla-Ocho/pilo-evals-judge#122 and docs/dev-sessions/2026-07-31-1100-issue-122-per-site-gaps/research.md in that repo.

🤖 Investigated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions