iflow-mcp_janspoerer-mcp_browser_use 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/METADATA +26 -0
  2. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/RECORD +50 -0
  3. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/WHEEL +5 -0
  4. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/top_level.txt +1 -0
  7. mcp_browser_use/__init__.py +2 -0
  8. mcp_browser_use/__main__.py +1347 -0
  9. mcp_browser_use/actions/__init__.py +1 -0
  10. mcp_browser_use/actions/elements.py +173 -0
  11. mcp_browser_use/actions/extraction.py +864 -0
  12. mcp_browser_use/actions/keyboard.py +43 -0
  13. mcp_browser_use/actions/navigation.py +73 -0
  14. mcp_browser_use/actions/screenshots.py +85 -0
  15. mcp_browser_use/browser/__init__.py +1 -0
  16. mcp_browser_use/browser/chrome.py +150 -0
  17. mcp_browser_use/browser/chrome_executable.py +204 -0
  18. mcp_browser_use/browser/chrome_launcher.py +330 -0
  19. mcp_browser_use/browser/chrome_process.py +104 -0
  20. mcp_browser_use/browser/devtools.py +230 -0
  21. mcp_browser_use/browser/driver.py +322 -0
  22. mcp_browser_use/browser/process.py +133 -0
  23. mcp_browser_use/cleaners.py +530 -0
  24. mcp_browser_use/config/__init__.py +30 -0
  25. mcp_browser_use/config/environment.py +155 -0
  26. mcp_browser_use/config/paths.py +97 -0
  27. mcp_browser_use/constants.py +68 -0
  28. mcp_browser_use/context.py +150 -0
  29. mcp_browser_use/context_pack.py +85 -0
  30. mcp_browser_use/decorators/__init__.py +13 -0
  31. mcp_browser_use/decorators/ensure.py +84 -0
  32. mcp_browser_use/decorators/envelope.py +83 -0
  33. mcp_browser_use/decorators/locking.py +172 -0
  34. mcp_browser_use/helpers.py +173 -0
  35. mcp_browser_use/helpers_context.py +261 -0
  36. mcp_browser_use/locking/__init__.py +1 -0
  37. mcp_browser_use/locking/action_lock.py +190 -0
  38. mcp_browser_use/locking/file_mutex.py +139 -0
  39. mcp_browser_use/locking/window_registry.py +178 -0
  40. mcp_browser_use/tools/__init__.py +59 -0
  41. mcp_browser_use/tools/browser_management.py +260 -0
  42. mcp_browser_use/tools/debugging.py +195 -0
  43. mcp_browser_use/tools/extraction.py +58 -0
  44. mcp_browser_use/tools/interaction.py +323 -0
  45. mcp_browser_use/tools/navigation.py +84 -0
  46. mcp_browser_use/tools/screenshots.py +116 -0
  47. mcp_browser_use/utils/__init__.py +1 -0
  48. mcp_browser_use/utils/diagnostics.py +85 -0
  49. mcp_browser_use/utils/html_utils.py +118 -0
  50. mcp_browser_use/utils/retry.py +57 -0
@@ -0,0 +1,83 @@
1
+ # mcp_browser_use/decorators/envelope.py
2
+
3
+ import os
4
+ import json
5
+ import asyncio
6
+ import inspect
7
+ import datetime
8
+ import functools
9
+ import traceback
10
+ from typing import Any, Callable
11
+
12
+
13
+ __all__ = [
14
+ "tool_envelope",
15
+ ]
16
+
17
+
18
+ def tool_envelope(func: Callable):
19
+ """
20
+ Minimal decorator for MCP tool functions:
21
+ - Works with both async and sync callables.
22
+ - On success: ensures the return value is a string (json.dumps for non-strings).
23
+ - On error: returns a uniform JSON string with a summary and optional traceback.
24
+ Environment:
25
+ - Set MBU_TOOL_ERRORS_TRACEBACK=0 to suppress traceback in error payloads.
26
+ """
27
+ include_tb = os.getenv("MBU_TOOL_ERRORS_TRACEBACK", "1") not in ("0", "false", "False")
28
+
29
+ def _normalize(value: Any) -> str:
30
+ if value is None:
31
+ return ""
32
+ if isinstance(value, str):
33
+ return value
34
+ if isinstance(value, bytes):
35
+ try:
36
+ return value.decode("utf-8")
37
+ except Exception:
38
+ return value.decode("utf-8", "replace")
39
+ try:
40
+ return json.dumps(value, ensure_ascii=False, default=lambda o: getattr(o, "__dict__", repr(o)))
41
+ except Exception:
42
+ # Fallback to a best-effort string
43
+ try:
44
+ return str(value)
45
+ except Exception:
46
+ return ""
47
+
48
+ def _error_payload(err: Exception) -> str:
49
+ tb = traceback.format_exc() if include_tb else None
50
+ payload = {
51
+ "ok": False,
52
+ "summary": f"{err.__class__.__name__}: {err}",
53
+ "error": {
54
+ "type": err.__class__.__name__,
55
+ "message": str(err),
56
+ },
57
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
58
+ }
59
+ if tb:
60
+ payload["error"]["traceback"] = tb
61
+ return json.dumps(payload, ensure_ascii=False)
62
+
63
+ if inspect.iscoroutinefunction(func):
64
+ @functools.wraps(func)
65
+ async def wrapper(*args, **kwargs):
66
+ try:
67
+ result = await func(*args, **kwargs)
68
+ except asyncio.CancelledError:
69
+ # Preserve cooperative cancellation semantics
70
+ raise
71
+ except Exception as e:
72
+ return _error_payload(e)
73
+ return _normalize(result)
74
+ return wrapper
75
+ else:
76
+ @functools.wraps(func)
77
+ def wrapper(*args, **kwargs):
78
+ try:
79
+ result = func(*args, **kwargs)
80
+ except Exception as e:
81
+ return _error_payload(e)
82
+ return _normalize(result)
83
+ return wrapper
@@ -0,0 +1,172 @@
1
+ # mcp_browser_use/decorators/locking.py
2
+ #
3
+ # Known Limitation: Iframe Context
4
+ # Multi-step iframe interactions require specifying iframe_selector for each action.
5
+ # This is intentional design to prevent context state bugs.
6
+
7
+
8
+ """
9
+ If multiple agents are working at the same time, there will be some natural
10
+ concurrency because the agents anyway need to produce tokens (think) before
11
+ calling the next action. This makes issues arising from sequential locking a
12
+ bit less pronounced.
13
+
14
+
15
+ """
16
+
17
+ import os
18
+ import json
19
+ import asyncio
20
+ import inspect
21
+ import warnings
22
+ import functools
23
+ import contextlib
24
+ import threading
25
+ import time as _time
26
+ from typing import Callable
27
+
28
+
29
+ __all__ = [
30
+ "exclusive_browser_access",
31
+ ]
32
+
33
+
34
+ def _validate_config_or_error():
35
+ """
36
+ Validate browser configuration early to provide clear error messages.
37
+
38
+ Returns None if valid, or JSON error string if invalid.
39
+ """
40
+ try:
41
+ from mcp_browser_use.config.environment import get_env_config
42
+ get_env_config() # Just validate - don't store since config never changes
43
+ return None # Valid config
44
+ except Exception as e:
45
+ error_payload = {
46
+ "ok": False,
47
+ "error": "invalid_configuration",
48
+ "message": f"Browser configuration error: {str(e)}. Please check your environment variables.",
49
+ "details": {
50
+ "required": ["CHROME_PROFILE_USER_DATA_DIR or BETA_PROFILE_USER_DATA_DIR or CANARY_PROFILE_USER_DATA_DIR"],
51
+ "optional": ["CHROME_EXECUTABLE_PATH", "CHROME_REMOTE_DEBUG_PORT"]
52
+ }
53
+ }
54
+ return json.dumps(error_payload)
55
+
56
+
57
+ def exclusive_browser_access(_func=None):
58
+ """
59
+ Acquire the action lock, keep it alive with a heartbeat while the function runs,
60
+ and renew once on exit. Also serializes calls within this process.
61
+ Use on tools that mutate or depend on exclusive browser access.
62
+ """
63
+
64
+ def decorator(func):
65
+ if inspect.iscoroutinefunction(func):
66
+ @functools.wraps(func)
67
+ async def wrapper(*args, **kwargs):
68
+ # Early config validation to prevent race condition
69
+ config_error = _validate_config_or_error()
70
+ if config_error:
71
+ return config_error
72
+
73
+ # Import lazily to avoid cycles at module import time
74
+ from mcp_browser_use.constants import ACTION_LOCK_TTL_SECS
75
+ from mcp_browser_use.locking.action_lock import (
76
+ get_intra_process_lock,
77
+ _acquire_action_lock_or_error,
78
+ _renew_action_lock,
79
+ )
80
+ from mcp_browser_use.browser.process import ensure_process_tag
81
+
82
+ # Ensure process tag exists
83
+ owner = ensure_process_tag()
84
+
85
+ # In-process serialization across tools
86
+ lock = get_intra_process_lock()
87
+ async with lock:
88
+ # Acquire cross-process action lock (waits up to ACTION_LOCK_WAIT_SECS)
89
+ err = _acquire_action_lock_or_error(owner)
90
+ if err:
91
+ # err is already a JSON string; let your tool_envelope pass it through
92
+ return err
93
+
94
+ stop = asyncio.Event()
95
+
96
+ async def _beater():
97
+ try:
98
+ while not stop.is_set():
99
+ try:
100
+ await asyncio.wait_for(stop.wait(), timeout=1.0)
101
+ break
102
+ except asyncio.TimeoutError:
103
+ pass
104
+ try:
105
+ _renew_action_lock(owner, ttl=ACTION_LOCK_TTL_SECS)
106
+ except Exception:
107
+ pass
108
+ except asyncio.CancelledError:
109
+ pass
110
+
111
+ task = asyncio.create_task(_beater())
112
+ try:
113
+ return await func(*args, **kwargs)
114
+ finally:
115
+ stop.set()
116
+ task.cancel()
117
+ with contextlib.suppress(asyncio.CancelledError, Exception):
118
+ await task
119
+ with contextlib.suppress(Exception):
120
+ _renew_action_lock(owner, ttl=ACTION_LOCK_TTL_SECS)
121
+ return wrapper
122
+
123
+ # Optional sync path (rare in your code); no asyncio.Lock here.
124
+ @functools.wraps(func)
125
+ def wrapper(*args, **kwargs):
126
+ # Early config validation to prevent race condition
127
+ config_error = _validate_config_or_error()
128
+ if config_error:
129
+ return config_error
130
+
131
+ from mcp_browser_use.constants import ACTION_LOCK_TTL_SECS
132
+ from mcp_browser_use.locking.action_lock import (
133
+ _acquire_action_lock_or_error,
134
+ _renew_action_lock,
135
+ )
136
+ from mcp_browser_use.browser.process import ensure_process_tag
137
+
138
+ # Ensure process tag exists
139
+ owner = ensure_process_tag()
140
+
141
+
142
+
143
+ err = _acquire_action_lock_or_error(owner)
144
+ if err:
145
+ return err
146
+
147
+ stop = False
148
+ def _beater():
149
+ nonlocal stop
150
+ while not stop:
151
+ _time.sleep(1.0)
152
+ try:
153
+ _renew_action_lock(owner, ttl=ACTION_LOCK_TTL_SECS)
154
+ except Exception:
155
+ pass
156
+
157
+ t = threading.Thread(target=_beater, daemon=True)
158
+ t.start()
159
+ try:
160
+ return func(*args, **kwargs)
161
+ finally:
162
+ stop = True
163
+ with contextlib.suppress(Exception):
164
+ t.join(timeout=0.5)
165
+ with contextlib.suppress(Exception):
166
+ _renew_action_lock(owner, ttl=ACTION_LOCK_TTL_SECS)
167
+
168
+ return wrapper
169
+
170
+ return decorator if _func is None else decorator(_func)
171
+
172
+
@@ -0,0 +1,173 @@
1
+ """
2
+ Helpers module - Compatibility layer.
3
+
4
+ This module now serves as a compatibility layer that re-exports functions
5
+ from the refactored modules. All actual implementations have been moved to:
6
+ - locking/ (file_mutex, action_lock, window_registry)
7
+ - browser/ (process, devtools, chrome, driver)
8
+ - actions/ (navigation, elements, screenshots, keyboard)
9
+ - utils/ (html_utils, retry, diagnostics)
10
+
11
+ The functions are re-exported here to maintain backward compatibility with
12
+ existing code that imports from helpers.
13
+ """
14
+
15
+ #region Imports
16
+ import os
17
+ import sys
18
+ import json
19
+ import time
20
+ import psutil
21
+ import socket
22
+ import shutil
23
+ import asyncio
24
+ import hashlib
25
+ import tempfile
26
+ import platform
27
+ import traceback
28
+ import subprocess
29
+ import contextlib
30
+ import urllib.request
31
+ from pathlib import Path
32
+ from bs4 import BeautifulSoup
33
+ from typing import Callable, Optional, Tuple, Dict, Any
34
+ import io
35
+ import base64
36
+
37
+ import logging
38
+ logger = logging.getLogger(__name__)
39
+ #endregion Imports
40
+
41
+ #region Browser
42
+ import selenium
43
+ from selenium import webdriver
44
+ from selenium.webdriver.common.by import By
45
+ from selenium.common.exceptions import (
46
+ TimeoutException,
47
+ NoSuchWindowException,
48
+ StaleElementReferenceException,
49
+ WebDriverException,
50
+ ElementClickInterceptedException,
51
+ )
52
+
53
+ from selenium.webdriver.support.ui import WebDriverWait
54
+ from selenium.webdriver.support import expected_conditions as EC
55
+ #endregion
56
+
57
+ #region Imports Dotenv
58
+ from dotenv import load_dotenv, find_dotenv
59
+ load_dotenv(find_dotenv(filename=".env", usecwd=True), override=True)
60
+ #endregion
61
+
62
+ #region Context Integration (Phase 2)
63
+ # Import new modules
64
+ from .context import get_context, reset_context, BrowserContext
65
+ from .constants import (
66
+ ACTION_LOCK_TTL_SECS as _ACTION_LOCK_TTL_SECS,
67
+ ACTION_LOCK_WAIT_SECS as _ACTION_LOCK_WAIT_SECS,
68
+ FILE_MUTEX_STALE_SECS as _FILE_MUTEX_STALE_SECS,
69
+ WINDOW_REGISTRY_STALE_THRESHOLD as _WINDOW_REGISTRY_STALE_THRESHOLD,
70
+ MAX_SNAPSHOT_CHARS as _MAX_SNAPSHOT_CHARS,
71
+ START_LOCK_WAIT_SEC as _START_LOCK_WAIT_SEC,
72
+ RENDEZVOUS_TTL_SEC as _RENDEZVOUS_TTL_SEC,
73
+ ALLOW_ATTACH_ANY as _ALLOW_ATTACH_ANY,
74
+ )
75
+ from .config.environment import get_env_config as _get_env_config, profile_key as _profile_key
76
+ from .config.paths import get_lock_dir as _get_lock_dir
77
+ #endregion
78
+
79
+ #region Constants / policy parameters (Backwards Compatible - delegates to constants.py)
80
+ # These now delegate to constants.py but maintain the old API
81
+ ACTION_LOCK_TTL_SECS = _ACTION_LOCK_TTL_SECS
82
+ ACTION_LOCK_WAIT_SECS = _ACTION_LOCK_WAIT_SECS
83
+ FILE_MUTEX_STALE_SECS = _FILE_MUTEX_STALE_SECS
84
+ WINDOW_REGISTRY_STALE_THRESHOLD = _WINDOW_REGISTRY_STALE_THRESHOLD
85
+ MAX_SNAPSHOT_CHARS = _MAX_SNAPSHOT_CHARS
86
+ START_LOCK_WAIT_SEC = _START_LOCK_WAIT_SEC
87
+ RENDEZVOUS_TTL_SEC = _RENDEZVOUS_TTL_SEC
88
+ ALLOW_ATTACH_ANY = _ALLOW_ATTACH_ANY
89
+ #endregion
90
+
91
+
92
+
93
+ #region Re-exports
94
+
95
+ # Core functions needed by decorators (internal but exported)
96
+ from .locking.action_lock import (
97
+ get_intra_process_lock, # Used by decorators
98
+ _acquire_action_lock_or_error, # Used by decorators
99
+ _renew_action_lock, # Used by decorators
100
+ _release_action_lock, # Used by tools
101
+ )
102
+
103
+ from .browser.process import (
104
+ ensure_process_tag, # Used by decorators/tools
105
+ make_process_tag, # Used internally
106
+ )
107
+
108
+ from .browser.driver import (
109
+ _ensure_driver_and_window, # Used by tools
110
+ _ensure_singleton_window, # Used by decorators
111
+ close_singleton_window, # Used by tools
112
+ _cleanup_own_blank_tabs, # Used by tools
113
+ _close_extra_blank_windows_safe, # Used by tools
114
+ )
115
+
116
+ from .actions.navigation import (
117
+ _wait_document_ready, # Used by tools
118
+ )
119
+
120
+ from .actions.screenshots import (
121
+ _make_page_snapshot, # Used by tools
122
+ )
123
+
124
+ # DO NOT re-export everything else - force migration
125
+ # If someone needs it, they import directly from the module
126
+ #endregion
127
+
128
+ # Export list - Only essentials
129
+ __all__ = [
130
+ # ===== Public API =====
131
+ # Context
132
+ 'get_context',
133
+ 'reset_context',
134
+ 'BrowserContext',
135
+
136
+ # Constants
137
+ 'ACTION_LOCK_TTL_SECS',
138
+ 'ACTION_LOCK_WAIT_SECS',
139
+ 'FILE_MUTEX_STALE_SECS',
140
+ 'WINDOW_REGISTRY_STALE_THRESHOLD',
141
+ 'MAX_SNAPSHOT_CHARS',
142
+ 'START_LOCK_WAIT_SEC',
143
+ 'RENDEZVOUS_TTL_SEC',
144
+ 'ALLOW_ATTACH_ANY',
145
+
146
+ # ===== Core Functions (Internal but needed by decorators/tools) =====
147
+ # Locking
148
+ 'get_intra_process_lock',
149
+ '_acquire_action_lock_or_error',
150
+ '_renew_action_lock',
151
+ '_release_action_lock',
152
+
153
+ # Process
154
+ 'ensure_process_tag',
155
+ 'make_process_tag',
156
+
157
+ # Driver
158
+ '_ensure_driver_and_window',
159
+ '_ensure_singleton_window',
160
+ 'close_singleton_window',
161
+ '_cleanup_own_blank_tabs',
162
+ '_close_extra_blank_windows_safe',
163
+
164
+ # Actions
165
+ '_wait_document_ready',
166
+ '_make_page_snapshot',
167
+ ]
168
+
169
+ # NOTE: For all other functions, import directly from the source module:
170
+ # from mcp_browser_use.actions.navigation import navigate_to_url
171
+ # from mcp_browser_use.actions.elements import click_element
172
+ # from mcp_browser_use.browser.chrome import start_or_attach_chrome_from_env
173
+ # etc.
@@ -0,0 +1,261 @@
1
+ # mcp_browser_use/helpers_context.py
2
+ import os
3
+ import time
4
+ import json as _json
5
+ from typing import Optional
6
+ from selenium.webdriver.support.ui import WebDriverWait
7
+ from .context_pack import ContextPack, ReturnMode
8
+ from .cleaners import basic_prune, approx_token_count, extract_outline
9
+
10
+
11
+ def _wait_for_dom_ready(driver, timeout=15):
12
+ WebDriverWait(driver=driver, timeout=timeout).until(
13
+ lambda d: d.execute_script("return document.readyState") == "complete"
14
+ )
15
+
16
+ def _apply_snapshot_settle():
17
+ settle_ms = int(os.getenv("SNAPSHOT_SETTLE_MS", "200")) # 0 disables
18
+ if settle_ms > 0:
19
+ time.sleep(settle_ms / 1000.0)
20
+
21
+ def get_outer_html(driver) -> str:
22
+ _wait_for_dom_ready(driver=driver)
23
+ _apply_snapshot_settle()
24
+ # If you currently use driver.page_source, keep that; both are fine.
25
+ return driver.execute_script("return document.documentElement.outerHTML")
26
+
27
+ def take_screenshot(driver, path: str):
28
+ _wait_for_dom_ready(driver=driver)
29
+ _apply_snapshot_settle()
30
+ driver.save_screenshot(path)
31
+
32
+ def pack_snapshot(
33
+ *,
34
+ window_tag: Optional[str],
35
+ url: Optional[str],
36
+ title: Optional[str],
37
+ raw_html: Optional[str],
38
+ return_mode: str,
39
+ cleaning_level: int,
40
+ token_budget: Optional[int],
41
+ text_offset: Optional[int] = None,
42
+ html_offset: Optional[int] = None,
43
+ ) -> ContextPack:
44
+ cp = ContextPack(
45
+ window_tag=window_tag,
46
+ url=url,
47
+ title=title,
48
+ cleaning_level_applied=cleaning_level,
49
+ snapshot_mode=return_mode,
50
+ tokens_budget=token_budget,
51
+ )
52
+
53
+ html = raw_html or ""
54
+ cleaned_html, pruned_counts = basic_prune(html=html, level=cleaning_level)
55
+ cp.pruned_counts = pruned_counts
56
+
57
+ if return_mode == ReturnMode.OUTLINE:
58
+ outline = extract_outline(html=cleaned_html)
59
+ cp.outline_present = True
60
+ cp.outline = [
61
+ # convert dict -> dataclass-ish dict; leaving as dict is fine for now
62
+ o for o in outline
63
+ ]
64
+ cp.approx_tokens = approx_token_count(text=" ".join([o["text"] for o in outline]))
65
+ return cp
66
+
67
+ if return_mode == ReturnMode.HTML:
68
+ # Apply html_offset if specified (for pagination through large HTML content)
69
+ if html_offset and html_offset > 0:
70
+ cleaned_html = cleaned_html[html_offset:]
71
+
72
+ # Respect token budget by truncating cleaned_html conservatively
73
+ if token_budget:
74
+ # Convert tokens -> chars budget ~4 chars/token
75
+ char_budget = token_budget * 4
76
+ if len(cleaned_html) > char_budget:
77
+ cleaned_html = cleaned_html[:char_budget]
78
+ cp.hard_capped = True
79
+ cp.html = cleaned_html
80
+ cp.approx_tokens = approx_token_count(text=cleaned_html)
81
+ return cp
82
+
83
+ if return_mode == ReturnMode.TEXT:
84
+ # Very naive visible text extraction through soup.get_text()
85
+ try:
86
+ from bs4 import BeautifulSoup
87
+ txt = BeautifulSoup(cleaned_html, "html.parser").get_text("\n", strip=True)
88
+ except Exception:
89
+ txt = ""
90
+
91
+ # Apply text_offset if specified (for pagination through large content)
92
+ if text_offset and text_offset > 0:
93
+ txt = txt[text_offset:]
94
+
95
+ if token_budget:
96
+ char_budget = token_budget * 4
97
+ if len(txt) > char_budget:
98
+ txt = txt[:char_budget]
99
+ cp.hard_capped = True
100
+ cp.text = txt
101
+ cp.approx_tokens = approx_token_count(text=txt)
102
+ return cp
103
+
104
+ # Fallback to outline
105
+ outline = extract_outline(html=cleaned_html)
106
+ cp.outline_present = True
107
+ cp.outline = [o for o in outline]
108
+ cp.approx_tokens = approx_token_count(text=" ".join([o["text"] for o in outline]))
109
+ return cp
110
+
111
+
112
+
113
+ def pack_from_snapshot_dict(
114
+ snapshot: dict,
115
+ window_tag: Optional[str],
116
+ return_mode: str,
117
+ cleaning_level: int,
118
+ token_budget: Optional[int],
119
+ text_offset: Optional[int] = None,
120
+ html_offset: Optional[int] = None,
121
+ ):
122
+ """
123
+ Build a ContextPack object from a raw snapshot dict and packing controls.
124
+
125
+ Applies structural/content pruning and optional truncation to fit the `token_budget`,
126
+ derives the selected representation (outline/text/html/dompaths/mixed), and attaches
127
+ metadata including `window_tag`.
128
+
129
+ Args:
130
+ snapshot: Raw snapshot dict (e.g., {"url", "title", "html", ...}).
131
+ window_tag: Optional identifier for the active window/tab.
132
+ return_mode: Target representation to materialize in the ContextPack.
133
+ {"outline", "text", "html", "dompaths", "mixed"}
134
+ cleaning_level: Structural/content cleaning intensity (0–3).
135
+ token_budget: Optional approximate token cap for the returned snapshot.
136
+ text_offset: Optional character offset to skip at the start of text (for pagination).
137
+ Only used when return_mode="text".
138
+ html_offset: Optional character offset to skip at the start of HTML (for pagination).
139
+ Only used when return_mode="html".
140
+
141
+ Returns:
142
+ ContextPack: The structured envelope ready for JSON serialization.
143
+
144
+ Notes:
145
+ - **Processing order**:
146
+ 1. Clean HTML (remove noise based on cleaning_level)
147
+ 2. Apply offset (skip first N chars)
148
+ 3. Apply token_budget (truncate to fit)
149
+
150
+ - **Offset behavior**:
151
+ - Applied to cleaned content, not raw HTML
152
+ - Character-based, not token-based
153
+ - If offset exceeds content length, returns empty string
154
+ - Use consistent cleaning_level across paginated calls
155
+
156
+ - Consider computing a `page_fingerprint` (e.g., sha256 of cleaned html) to assist
157
+ agents in cheap change detection between steps.
158
+ """
159
+ return pack_snapshot(
160
+ window_tag=window_tag,
161
+ url=snapshot.get("url"),
162
+ title=snapshot.get("title"),
163
+ raw_html=snapshot.get("html"),
164
+ return_mode=return_mode,
165
+ cleaning_level=cleaning_level,
166
+ token_budget=token_budget,
167
+ text_offset=text_offset,
168
+ html_offset=html_offset,
169
+ )
170
+
171
+
172
+ async def to_context_pack(result_json: str, return_mode: str, cleaning_level: int, token_budget=1000, text_offset: Optional[int] = None, html_offset: Optional[int] = None) -> str:
173
+ """
174
+ Convert a helper's raw JSON result into a JSON-serialized ContextPack envelope.
175
+
176
+ Parses a helper response (typically including a "snapshot" dict and auxiliary fields),
177
+ normalizes `return_mode`, fetches current page metadata, and produces a size-controlled,
178
+ structured ContextPack. Any non-snapshot fields from the helper are surfaced under
179
+ the ContextPack's auxiliary section (e.g., `mixed`). Helper-reported errors (e.g.,
180
+ ok=false) are surfaced in `errors`.
181
+
182
+ Args:
183
+ result_json: JSON string returned by a helper call (must parse to a dict).
184
+ return_mode: Desired snapshot representation {"outline","text","html","dompaths","mixed"}.
185
+ cleaning_level: Structural/content cleaning intensity (0–3).
186
+ token_budget: Approximate token cap for the returned snapshot. Should usually be 5_000 or lower.
187
+ text_offset: Optional character offset for text mode pagination.
188
+ html_offset: Optional character offset for html mode pagination.
189
+
190
+ Returns:
191
+ str: JSON-serialized ContextPack.
192
+
193
+ Raises:
194
+ TypeError: If `result_json` is not valid JSON or is not a dict after parsing.
195
+ ValueError: If `return_mode` is invalid (normalized internally to a default).
196
+ """
197
+ # Import here to avoid circular dependency at module load time
198
+ import mcp_browser_use.helpers as helpers
199
+
200
+ try:
201
+ obj = _json.loads(result_json)
202
+ except Exception:
203
+ raise TypeError(f"helper returned non-JSON: {type(result_json)}")
204
+
205
+ # Normalize/validate return_mode
206
+ mode = (return_mode or "outline").lower()
207
+ if mode not in {"html", "text", "outline", "dompaths", "mixed"}:
208
+ mode = "outline"
209
+
210
+ try:
211
+ meta = await helpers.get_current_page_meta()
212
+ except Exception:
213
+ meta = {"url": None, "title": None, "window_tag": None}
214
+
215
+ snap = obj.get("snapshot")
216
+ if not isinstance(snap, dict):
217
+ snap = {"url": meta.get("url"), "title": meta.get("title"), "html": ""}
218
+
219
+ cp = pack_from_snapshot_dict(
220
+ snapshot=snap,
221
+ window_tag=meta.get("window_tag"),
222
+ return_mode=mode,
223
+ cleaning_level=cleaning_level,
224
+ token_budget=token_budget,
225
+ text_offset=text_offset,
226
+ html_offset=html_offset,
227
+ )
228
+
229
+ # Add warning if token budget is too high
230
+ if token_budget and token_budget > 10_000:
231
+ cp.errors.append({
232
+ "type": "warning",
233
+ "summary": "High token budget detected",
234
+ "details": {
235
+ "requested_token_budget": token_budget,
236
+ "message": (
237
+ f"You requested a token budget of {token_budget} tokens, which may clog your context. "
238
+ "Consider using the extract_elements tool with structured extraction (MODE 2) "
239
+ "to extract only the specific data you need. This can reduce token usage by 90%+ "
240
+ "while getting more precise results. "
241
+ "Example: extract_elements(container_selector='div.product', fields=[...], max_items=50)"
242
+ ),
243
+ "recommendation": "Use extract_elements tool for targeted data extraction instead of large snapshots"
244
+ }
245
+ })
246
+
247
+ # Surface errors in a first-class place
248
+ if obj.get("ok") is False:
249
+ try:
250
+ cp.errors.append({
251
+ "type": obj.get("error") or "error",
252
+ "summary": obj.get("summary"),
253
+ "details": {k: v for k, v in obj.items() if k != "snapshot"},
254
+ })
255
+ except Exception:
256
+ pass
257
+
258
+ leftovers = {k: v for k, v in obj.items() if k != "snapshot"}
259
+ cp.mixed = leftovers
260
+
261
+ return _json.dumps(cp, default=lambda o: getattr(o, "__dict__", repr(o)), ensure_ascii=False)
@@ -0,0 +1 @@
1
+ """Multi-agent coordination and locking."""