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
-
The browser layer throws BrowserDisconnectedError — packages/core/src/browser/playwrightBrowser.ts:729.
-
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.
-
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)"
}
}
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."
- So the dispatch at
webAgent.ts:738 can never match for this class:
if (error instanceof BrowserDisconnectedError) {
await this.handleBrowserDisconnect(task, error, executionState);
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.
- 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:
- Point pilo at a CDP endpoint you can kill (local
chromedp/headless-shell or browserless), PILO_REQUIRE_CDP=true.
- Start a multi-step task; once it's interacting with the page, kill the browser container.
- 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
Summary
When a tool's
execute()throws, the AI SDK records the error as a tool result rather than propagating the throw, andaiResponse.toolResultscomes back empty.webAgent.tsreads emptytoolResultsas "the model called no tool" and throwsToolExecutionError("You must use exactly one tool."), discarding the original error's type.For
BrowserDisconnectedErrorthis is expensive: it is exactly the errorhandleBrowserDisconnect()exists to recover from, and theinstanceofcheck that would trigger recovery can never match. Pilo has complete, correct reconnect logic that is unreachable via this path.Impact
Measured on published
hundred.jsonleval runs, build7d0f381688f5394892eead1fe28fe4fd7ebf6a9f:browser:reconnectedfired 0 times across all 19, despite a realBrowserDisconnectedErrorin every one.toolCallMalformedcountConcentrated 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
The browser layer throws
BrowserDisconnectedError—packages/core/src/browser/playwrightBrowser.ts:729.BrowserDisconnectedError extends RecoverableError, notBrowserException(packages/core/src/errors.ts:184), sowebActionTools.ts:255'sinstanceof BrowserExceptioncatch correctly does not swallow it and line 287 re-throws. This part works as intended.But the throw happens inside the AI SDK's tool execution, beneath
streamText. The SDK catches it, records a tool result withoutput.type === 'error-text', and resolves normally — withtoolResultsempty. 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)" } }packages/core/src/webAgent.ts:1296-1300then treats that as a no-tool-call:This is the defect.
toolResults.length === 0conflates "the model called no tool" with "the tool the model called threw."webAgent.ts:738can never match for this class:addErrorFeedback()(webAgent.ts:889) then early-returns forToolExecutionError:Sound in general, but false for the branch at line 1297 — which fires precisely because there is no tool result carrying that information.
error-textin 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_callto separate prose-instead-of-tool from truncation. It records a third case neither hypothesis covers: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?.lengthbranch, distinguish the two cases before throwing: inspect the returnedresponse.messagesfor atool-resultpart withoutput.type === 'error-text'and re-throw the original typed error (minimally, throwBrowserDisconnectedErrorwhen the value matches) so the existinginstanceofdispatch 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
addErrorFeedbackshould 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-texttool result,finishReason: 'tool-calls'with empty text, and theToolExecutionErrorfiring — but nobody has confirmed againstai@^7.0.16thattoolResultsexcludes 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.
BrowserDisconnectedErroris the one with a measured cost.Reproduction
Not yet run. Proposed:
chromedp/headless-shellor browserless),PILO_REQUIRE_CDP=true.tool:execution:error= "You must use exactly one tool", nobrowser:reconnected, agent aborts within ~4 iterations blaming the browser. Fixed:browser:reconnectedemitted, task restarts on the next endpoint.A unit test is the better regression guard — stub the AI call to return
toolResults: []plus aresponse.messagesentry containing anerror-textoutput whose value is aBrowserDisconnectedErrormessage, and asserthandleBrowserDisconnect()is invoked.packages/core/test/webAgent.test.tsalready stubs generation this way.Source claims cited against
mozilla/pilo@7446077e. Evidence: 19 task artifacts fromscheduled-bu-pilo-tab-hundredruns 1785337200 / 1785423600, participantpilo-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 anddocs/dev-sessions/2026-07-31-1100-issue-122-per-site-gaps/research.mdin that repo.🤖 Investigated with Claude Code