Skip to content

[Fix] Stop the browser worker from looping on no-progress calls - #10

Closed
sirotaoleg wants to merge 2 commits into
ThinkFlowLab:jiuwen-jevfrom
sirotaoleg:pr/browser-repeated-call-guard
Closed

sirotaoleg wants to merge 2 commits into
ThinkFlowLab:jiuwen-jevfrom
sirotaoleg:pr/browser-repeated-call-guard

Conversation

@sirotaoleg

Copy link
Copy Markdown

Depends on #<PR 3 number> — the diff contains its commit until it is merged. Review only the last commit.

Problem. After the worker already has the answer, it sometimes keeps calling a tool that does nothing, until it hits the step limit:

  • browser_evaluate("() => 1"), () => document.title, () => 'ok';
  • later also with a changing counter: 'done19', 'done20', … or ()=>34, ()=>35, ….

Fix. RepeatedCallGuardRail counts, per worker run:

  • identical consecutive calls with identical results (for browser_evaluate, digits are ignored, so 'done19' = 'done20');
  • consecutive "no-op" evaluates: functions that only return a constant or the page title/URL. Anything that reads page content is not a no-op.

From the 3rd call in such a streak, the tool message gets a short note: "this repeats, answer now or do something different". After the 5th, the next model call is sent without tools, and one prompt section asks for the final JSON.

  • State lives per invoke (ctx.extra) and is reset in before_invoke.
  • The structured tool result is untouched.
  • A test shows the request really goes out with no tools.

Result.

  • On a 122-task re-run, the tasks that failed before went 0/92 → 68/92. That run also used temperature 1.0 / top_p 1.0, see the note below. LLM calls −54%, cost −50%, time −36%.
  • Adding no-op streaks and digit masking brought all 8 remaining step-limit loops under the limit (second re-run).
  • False-positive check: replaying the rule over 375 Fable trajectories gives 0 hits.

Tests. test_browser_repeated_call_guard.py:

  • normalisation;
  • fingerprint without volatile Playwright lines;
  • counting and resets;
  • the no-op grammar (true/false cases);
  • no false positives on loading pages or reading changing content;
  • end-to-end with a fake LLM that loops and then answers once tools are withheld.

Note: the worker's default sampling (temperature 0.2 / top_p 0.1) is not changed here. Raising it clearly reduced loops for Sonnet, but that is a separate decision that needs a check on other models.

Oleg added 2 commits September 30, 2026 16:36
When a Playwright MCP action fails because another element sits on top
of the target, the worker model got the full Playwright call log: 1.9-5.6
KB of ANSI-coloured retry noise in which the blocker is one line. On
WebVoyager (@playwright/mcp 0.0.78, agent-core 18b840c) 12 of 90 tasks
hit "element intercepts pointer events" (18 failures, each after a 5 s
timeout), and it took 4.5 steps on average (54 in total, worst 8-9) to
get from the failed click to the next successful one. Blockers were the
OneTrust consent dialog (Coursera, Cambridge, Allrecipes), Amazon's
redirect modal, Amazon's radio icon over its own input, and a Google
overlay.

Add InterceptedActionHintRail, registered on the Playwright worker by
BrowserService.ensure_started(). Its after_tool_call parses the call log
(target from the last "locator resolved to", blocker and container from
the last "... intercepts pointer events" line) and replaces only the
model-facing ToolMessage content with a short error, keeping the
{"result": ...} envelope and the "### Error" marker:

  before (2.4 KB, shortened):
    {"result": "### Error\nTimeoutError: browserBackend.callTool: Timeout
    5000ms exceeded.\nCall log:\n  \u001b[2m- waiting for locator(...)
    ... - <div class=\"onetrust-pc-dark-filter ot-fade-in\"></div> from
    <div ... id=\"onetrust-consent-sdk\">…</div> subtree intercepts
    pointer events ... - retrying click action ... (x24)"}
  after (394 chars):
    ### Error
    Click not delivered: button.css-bnpmeq[type="button"][data-testid=
    "filter-and-sort-button"] is covered by div.onetrust-pc-dark-filter
    (inside div#onetrust-consent-sdk).
    This is a cookie-consent dialog: close it first (prefer 'Reject all'
    / 'Close'; if its settings panel is open, confirm or close that
    panel).
    Then retry the action (take a fresh browser_snapshot if the page
    changed).

Non-consent blockers get a generic hint (dismiss the overlay, or click
the control's visible label when an icon covers its input). Detection is
by content, so every action tool (click, hover, type, fill_form,
select_option, drag, ...) is covered. Content that does not mention an
intercepted action, or whose call log cannot be parsed, is left
byte-identical.

A rail rather than a wrapper around ability_manager.execute: S_05 (tools
contract, invariant 11) makes AFTER_TOOL_CALL the place that rewrites
tool_msg, and resolve_tool_message/rendered_result read that message,
so what is streamed and what the model reads stay the same text. The
structured tool_result is untouched, so tool_result_observer (progress
and failure tracking) still receives the raw result.

Out of scope: auto-dismissing consent banners, a blocked_by field in the
probe tools, the Amazon geo redirect, and porting to the refactor
layout (browser_move/runtime/..., legacy MCP path).
On a full WebVoyager run (556 tasks, @playwright/mcp 0.0.78, Claude
Sonnet via OpenRouter, 50 steps and 600 s per task), 107 tasks made 5+
identical consecutive tool calls, and 49 of the 59 max_iterations
failures were exactly this loop. The model usually has the answer
already: in Allrecipes--0 it had the recipe (4.6 stars, 117 ratings,
serves 6) by step 15, then called browser_evaluate("() => 'ok'") 35
times until the step limit. Common loop bodies: () => 1,
() => document.title, () => location.href; later also varying counters
('done19', 'done20', ... or ()=>34, ()=>35, ...). Raising sampling to
temperature 1.0 / top_p 1.0 reduced the loops but did not remove them,
so the runtime needs a guard regardless of model and sampling.

Add RepeatedCallGuardRail, registered on the Playwright worker in
BrowserService.ensure_started(). Per worker run it counts two streaks:

- identical calls with identical results: key = tool name without the
  MCP prefix + canonical JSON arguments; fingerprint = sha256 of the
  model-facing content without Playwright's volatile parts (### Events,
  "New console entries", console counts). For browser_evaluate the
  function is compared without whitespace and with digit runs masked;
  other tools compare exactly (clicking e12 is not clicking e13);
- no-op evaluates: browser_evaluate functions that only return literals,
  document.title, document.URL, location.href or a flat object/array of
  those, whatever their arguments. Anything that reads page content is
  not a no-op.

From the 3rd call of a streak the tool message gets a short
"### Repeated call" note (inside the {"result": ...} envelope when there
is one, never twice). After the 5th, before_model_call sends the next
request with no tools and one prompt section asking for the final JSON,
the way BrowserRuntimeRail._prepare_terminal_synthesis does. ReActAgent
runs BEFORE_MODEL_CALL rails before building the context window from
ctx.inputs.tools, so the request really carries no tools (asserted in
the test). State lives in ctx.extra and is reset in before_invoke.

The structured tool_result and what tool_result_observer receives are
unchanged. Replaying the rule over 375 logged Fable trajectories gives 0
hits. The registration tests of this rail and InterceptedActionHintRail
search all register_rail calls instead of only the last one.

Out of scope: worker sampling defaults, loops across different tools,
and porting to the refactor layout (browser_move/runtime/...).
@sirotaoleg

Copy link
Copy Markdown
Author

Superseded by #12

@sirotaoleg sirotaoleg closed this Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant