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(),