browser_agent: --no-sandbox flag; OpenRouter/sanitize helpers

Launch Chromium with --no-sandbox alongside --headless=new so the browser agent runs in containers without sandbox failures (temp profile missing bug). Factor LLM workarounds into helpers: sanitize structured JSON before browser-use/Pydantic parsing, and normalize OpenRouter requests that use strict json_schema (lighter json_object path plus schema hint in messages, relax strict tool flags so providers like Anthropic don't error out).

Alessandro committed Mar 25, 2026 at 21:17 UTC 153a1e521d3328b7b8e806680b2ea263a699821b
5 files changed +204 -10
plugins/_browser_agent/helpers/browser_llm.py
+27 -9
@@ -6,9 +6,10 @@ from langchain_core.messages import BaseMessage
6
7 import models
8 from browser_use.llm import ChatGoogle, ChatOpenRouter
9 -from helpers import dirty_json
9
10 from plugins._browser_agent.helpers import browser_use_monkeypatch
11 +from plugins._browser_agent.helpers import browser_use_openrouter_compat
12 +from plugins._browser_agent.helpers import browser_use_output_sanitize
13
14
15 _BROWSER_USE_PATCHED = False
@@ -76,15 +77,29 @@ class BrowserCompatibleChatWrapper(ChatOpenRouter):
77
78 try:
79 model = kwargs.pop("model", None)
80 + effective_model = model or self._wrapper.model_name
81 kwrgs = {**self._wrapper.kwargs, **kwargs}
82 + request_messages = messages
83
84 # hack from browser-use to fix json schema for gemini (additionalProperties, $defs, $ref)
82 - if "response_format" in kwrgs and "json_schema" in kwrgs["response_format"] and model and model.startswith("gemini/"):
85 + if "response_format" in kwrgs and "json_schema" in kwrgs["response_format"] and effective_model and effective_model.startswith("gemini/"):
86 kwrgs["response_format"]["json_schema"] = ChatGoogle("")._fix_gemini_schema(kwrgs["response_format"]["json_schema"])
87
88 + if browser_use_openrouter_compat.should_use_openrouter_prompt_schema_fallback(
89 + provider=self.provider,
90 + model_name=effective_model,
91 + kwargs=kwrgs,
92 + ):
93 + fallback_request = browser_use_openrouter_compat.build_json_object_fallback_request(
94 + messages=messages,
95 + kwargs=kwrgs,
96 + )
97 + if fallback_request is not None:
98 + request_messages, kwrgs = fallback_request
99 +
100 resp = await acompletion(
101 model=self._wrapper.model_name,
87 - messages=messages,
102 + messages=request_messages,
103 stop=stop,
104 **kwrgs,
105 )
@@ -102,13 +117,16 @@ class BrowserCompatibleChatWrapper(ChatOpenRouter):
117 except Exception as e:
118 raise e
119
105 - # another hack for browser-use post process invalid jsons
120 + # Structured output: normalize keys/models reject (e.g. "" on action dicts) and repair partial JSON
121 try:
107 - if "response_format" in kwrgs and "json_schema" in kwrgs["response_format"] or "json_object" in kwrgs["response_format"]:
108 - if resp.choices[0].message.content is not None and not resp.choices[0].message.content.startswith("{"): # type: ignore
109 - js = dirty_json.parse(resp.choices[0].message.content) # type: ignore
110 - resp.choices[0].message.content = dirty_json.stringify(js) # type: ignore
111 - except Exception as e:
122 + rf = kwrgs.get("response_format") or {}
123 + if "json_schema" in rf or "json_object" in rf:
124 + msg_obj = resp.choices[0].message
125 + raw_content = getattr(msg_obj, "content", None)
126 + fixed = browser_use_output_sanitize.sanitize_llm_message_content_for_browser_use(raw_content) # type: ignore[arg-type]
127 + if fixed is not None:
128 + msg_obj.content = fixed
129 + except Exception:
130 pass
131
132 return resp
plugins/_browser_agent/helpers/browser_use_monkeypatch.py
+4
@@ -2,6 +2,8 @@ from typing import Any
2 from browser_use.llm import ChatGoogle
3 from helpers import dirty_json
4
5 +from plugins._browser_agent.helpers import browser_use_output_sanitize
6 +
7
8 # ------------------------------------------------------------------------------
9 # Gemini Helper for Output Conformance
@@ -22,6 +24,8 @@ def gemini_clean_and_conform(text: str):
24 if not isinstance(obj, dict):
25 return None
26
27 + obj = browser_use_output_sanitize.normalize_parsed_browser_use_output(obj)
28 +
29 # Conform actions to browser-use expectations
30 if isinstance(obj.get("action"), list):
31 normalized_actions = []
plugins/_browser_agent/helpers/browser_use_openrouter_compat.py new
+93
@@ -0,0 +1,93 @@
1 +from __future__ import annotations
2 +
3 +import copy
4 +import json
5 +from typing import Any
6 +
7 +def is_openrouter_request(provider: str | None, model_name: str | None) -> bool:
8 + provider_name = (provider or "").lower()
9 + model = (model_name or "").lower()
10 + return provider_name == "openrouter" or model.startswith("openrouter/")
11 +
12 +
13 +def has_json_schema_response_format(kwargs: dict[str, Any]) -> bool:
14 + response_format = kwargs.get("response_format")
15 + return isinstance(response_format, dict) and (
16 + response_format.get("type") == "json_schema" or "json_schema" in response_format
17 + )
18 +
19 +
20 +def should_use_openrouter_prompt_schema_fallback(
21 + provider: str | None, model_name: str | None, kwargs: dict[str, Any]
22 +) -> bool:
23 + """
24 + OpenRouter sometimes routes browser-use structured output through providers
25 + that reject large compiled grammars. Avoid the hard error entirely by
26 + downgrading to `json_object` before the first request.
27 + """
28 + return is_openrouter_request(provider, model_name) and has_json_schema_response_format(kwargs)
29 +
30 +
31 +def relax_strict_tool_schemas(tools: Any) -> Any:
32 + """
33 + Disable strict tool grammar on fallback while keeping tool definitions intact.
34 + """
35 + if not isinstance(tools, list):
36 + return tools
37 +
38 + relaxed = copy.deepcopy(tools)
39 + for tool in relaxed:
40 + if not isinstance(tool, dict):
41 + continue
42 + function_spec = tool.get("function")
43 + if isinstance(function_spec, dict) and function_spec.get("strict") is True:
44 + function_spec["strict"] = False
45 + return relaxed
46 +
47 +
48 +def _schema_hint_text(response_format: dict[str, Any]) -> str | None:
49 + schema_payload = response_format.get("json_schema")
50 + if not isinstance(schema_payload, dict):
51 + return None
52 +
53 + compact_schema = json.dumps(
54 + schema_payload,
55 + ensure_ascii=False,
56 + separators=(",", ":"),
57 + )
58 + return (
59 + "Return only a single JSON object with no markdown fences, prose, or extra text. "
60 + "Follow this schema exactly: "
61 + f"{compact_schema}"
62 + )
63 +
64 +
65 +def prepend_schema_hint_to_messages(
66 + messages: list[Any], response_format: dict[str, Any]
67 +) -> list[Any]:
68 + hint = _schema_hint_text(response_format)
69 + if not hint:
70 + return list(messages)
71 + return [{"role": "system", "content": hint}, *list(messages)]
72 +
73 +
74 +def build_json_object_fallback_request(
75 + messages: list[Any],
76 + kwargs: dict[str, Any],
77 +) -> tuple[list[Any], dict[str, Any]] | None:
78 + """
79 + Replace strict json_schema with json_object and move schema guidance into the prompt.
80 +
81 + This keeps browser-use's local validation path while avoiding provider-side
82 + grammar compilation limits on OpenRouter.
83 + """
84 + response_format = kwargs.get("response_format")
85 + if not isinstance(response_format, dict):
86 + return None
87 +
88 + updated_kwargs = copy.deepcopy(kwargs)
89 + updated_kwargs["response_format"] = {"type": "json_object"}
90 + if "tools" in updated_kwargs:
91 + updated_kwargs["tools"] = relax_strict_tool_schemas(updated_kwargs["tools"])
92 + updated_messages = prepend_schema_hint_to_messages(messages, response_format)
93 + return updated_messages, updated_kwargs
plugins/_browser_agent/helpers/browser_use_output_sanitize.py new
+79
@@ -0,0 +1,79 @@
1 +"""
2 +Utilities to normalize LLM replies before browser-use parses them into AgentOutput.
3 +
4 +Some models (e.g. via OpenRouter) emit extra JSON keys such as "" : "", which
5 +Pydantic rejects as extra_forbidden on strict action union members.
6 +"""
7 +
8 +from __future__ import annotations
9 +
10 +from typing import Any
11 +
12 +from helpers import dirty_json
13 +
14 +
15 +def deep_strip_empty_string_keys(obj: Any) -> Any:
16 + """
17 + Recursively remove dict entries whose key is the empty string.
18 +
19 + Browser-use action objects must be discriminated unions with a single
20 + action key; spurious "" keys break validation for every union variant.
21 + """
22 + if isinstance(obj, dict):
23 + return {
24 + k: deep_strip_empty_string_keys(v)
25 + for k, v in obj.items()
26 + if k != ""
27 + }
28 + if isinstance(obj, list):
29 + return [deep_strip_empty_string_keys(item) for item in obj]
30 + return obj
31 +
32 +
33 +def normalize_parsed_browser_use_output(obj: dict) -> dict:
34 + """Apply all normalizations safe for a parsed AgentOutput-shaped dict."""
35 + out = deep_strip_empty_string_keys(obj)
36 + if not isinstance(out, dict):
37 + return obj
38 + return out
39 +
40 +
41 +def parse_and_sanitize_llm_json(text: str) -> str | None:
42 + """
43 + Parse message content and return JSON text safe for AgentOutput parsing.
44 +
45 + Returns None if the string is not a JSON object.
46 + """
47 + try:
48 + obj = dirty_json.parse(text)
49 + except Exception:
50 + return None
51 + if not isinstance(obj, dict):
52 + return None
53 + return dirty_json.stringify(normalize_parsed_browser_use_output(obj))
54 +
55 +
56 +def sanitize_llm_message_content_for_browser_use(content: str | None) -> str | None:
57 + """
58 + Best-effort sanitize assistant message content in place for browser-use.
59 +
60 + - If content parses as a dict: strip bad keys and re-serialize.
61 + - If content is non-JSON or trailing garbage: try dirty_json parse; if dict, sanitize.
62 + - Otherwise return the original string.
63 + """
64 + if content is None:
65 + return None
66 + stripped = content.strip()
67 + if not stripped:
68 + return content
69 + sanitized = parse_and_sanitize_llm_json(stripped)
70 + if sanitized is not None:
71 + return sanitized
72 + if not stripped.startswith("{"):
73 + try:
74 + obj = dirty_json.parse(stripped)
75 + except Exception:
76 + return content
77 + if isinstance(obj, dict):
78 + return dirty_json.stringify(normalize_parsed_browser_use_output(obj))
79 + return content
plugins/_browser_agent/tools/browser_agent.py
+1 -1
@@ -79,7 +79,7 @@ class State:
79 screen={"width": 1024, "height": 2048},
80 viewport={"width": 1024, "height": 2048},
81 no_viewport=False,
82 - args=["--headless=new"],
82 + args=["--headless=new", "--no-sandbox"],
83 # Use a unique user data directory to avoid conflicts
84 user_data_dir=self.get_user_data_dir(),
85 extra_http_headers=self._get_browser_http_headers(),