@capagents/forgeqe 0.0.0-stage → 1.0.1

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 (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +781 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,93 @@
1
+ """Engine settings read from the environment: library tuning, headless mode, and the default sign-in."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Any, Dict, Optional, Tuple
8
+
9
+ logger = logging.getLogger("forgeqe_engine.service")
10
+
11
+
12
+ def _truthy_env(name: str, default: bool = False) -> bool:
13
+ raw = os.getenv(name)
14
+ if raw is None:
15
+ return default
16
+ return str(raw).strip().lower() in {"1", "true", "yes", "on"}
17
+
18
+
19
+ def read_default_browser_login_credentials() -> Optional[Tuple[str, str]]:
20
+ """(username, password) from BROWSER_USE_DEFAULT_LOGIN_USERNAME / _PASSWORD when both are set."""
21
+ if _truthy_env("BROWSER_USE_SKIP_DEFAULT_LOGIN_CREDENTIALS"):
22
+ return None
23
+ user = (os.environ.get("BROWSER_USE_DEFAULT_LOGIN_USERNAME") or "").strip()
24
+ password = (os.environ.get("BROWSER_USE_DEFAULT_LOGIN_PASSWORD") or "").strip()
25
+ if not user and not password:
26
+ return None
27
+ if not user or not password:
28
+ logger.warning("default login is incomplete (need both username and password); skipping")
29
+ return None
30
+ return user, password
31
+
32
+
33
+ def build_default_browser_login_system_extension() -> Optional[str]:
34
+ creds = read_default_browser_login_credentials()
35
+ if not creds:
36
+ return None
37
+ user, password = creds
38
+ return f"""## Default application sign-in (use when the scenario requires authentication)
39
+ When the user goal involves **logging in**, **sign in**, **authentication**, or you reach a **login / password** form and the goal did **not** specify different credentials, fill the form using:
40
+ - **Email or username:** `{user}`
41
+ - **Password:** `{password}`
42
+
43
+ Prefer fields labeled Email, Username, or Login together with Password. Submit the form and wait for a successful post-login state before continuing. If the page already shows a signed-in session, do not re-enter credentials unless the flow requires re-auth.""".strip()
44
+
45
+
46
+ def _browser_use_agent_tuning_kwargs() -> Dict[str, Any]:
47
+ """
48
+ Library 0.12+ defaults (planning, thinking, judge, vision) add large per-step latency
49
+ on heavy sites (e.g. booking.com). Fast mode is on unless BROWSER_USE_FULL_AGENT_MODE=1.
50
+ """
51
+ if _truthy_env("BROWSER_USE_FULL_AGENT_MODE"):
52
+ return {}
53
+ if not _truthy_env("BROWSER_USE_FAST_MODE", default=True):
54
+ return {}
55
+ tuning: Dict[str, Any] = {
56
+ "enable_planning": _truthy_env("BROWSER_USE_ENABLE_PLANNING", default=False),
57
+ "use_thinking": _truthy_env("BROWSER_USE_USE_THINKING", default=False),
58
+ # One model call after done that checks the trace and screenshots against the goal; its verdict decides success.
59
+ # Off by default (browser.judge): it failed runs whose answer came from one screenshot rather than a step per item.
60
+ "use_judge": _truthy_env("BROWSER_USE_USE_JUDGE", default=False),
61
+ "flash_mode": _truthy_env("BROWSER_USE_FLASH_MODE", default=True),
62
+ "loop_detection_enabled": _truthy_env("BROWSER_USE_LOOP_DETECTION", default=True),
63
+ # Later steps of a long run were resending every earlier step, so exploration got slower
64
+ # as it went. Keep the first step and the most recent ones. Summarising the rest is another
65
+ # model call on the step path, which costs more time than it saves.
66
+ "max_history_items": 8,
67
+ "message_compaction": False,
68
+ }
69
+ loop_window_raw = (os.getenv("BROWSER_USE_LOOP_DETECTION_WINDOW") or "").strip()
70
+ if loop_window_raw.isdigit():
71
+ tuning["loop_detection_window"] = max(5, int(loop_window_raw))
72
+ history_raw = (os.getenv("BROWSER_USE_MAX_HISTORY_ITEMS") or "").strip()
73
+ if history_raw.isdigit():
74
+ tuning["max_history_items"] = max(6, int(history_raw))
75
+ if _truthy_env("BROWSER_USE_LOW_VISION_DETAIL", default=True):
76
+ tuning["vision_detail_level"] = "low"
77
+ max_actions = (os.getenv("BROWSER_USE_MAX_ACTIONS_PER_STEP") or "").strip()
78
+ if max_actions.isdigit():
79
+ tuning["max_actions_per_step"] = max(1, int(max_actions))
80
+ llm_timeout_raw = (os.getenv("BROWSER_USE_LLM_TIMEOUT") or "").strip()
81
+ if llm_timeout_raw.isdigit():
82
+ tuning["llm_timeout"] = max(30, int(llm_timeout_raw))
83
+ max_failures_raw = (os.getenv("BROWSER_USE_MAX_FAILURES") or "").strip()
84
+ if max_failures_raw.isdigit():
85
+ tuning["max_failures"] = max(1, int(max_failures_raw))
86
+ return tuning
87
+
88
+
89
+ def resolve_browser_headless(headed: Optional[bool] = None) -> bool:
90
+ """headed=True → visible browser. When omitted, BROWSER_USE_HEADLESS decides (default visible)."""
91
+ if headed is not None:
92
+ return not bool(headed)
93
+ return _truthy_env("BROWSER_USE_HEADLESS", default=False)
@@ -0,0 +1,88 @@
1
+ """Skills for the browser agent: choosing which ones fit a goal, and the prompt text for the chosen ones."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Any, Dict, List, Tuple
8
+
9
+ from .prompts import text as prompt_text
10
+
11
+ logger = logging.getLogger("forgeqe_engine")
12
+
13
+ MAX_CHOSEN = 3
14
+ # The choice runs before the agent, outside the run's own timeout: a model that hangs here costs at most this long.
15
+ SELECT_TIMEOUT_SECONDS = 60.0
16
+
17
+ SELECT_SYSTEM = (
18
+ "You pick playbooks for a browser automation agent. Each playbook explains how to work with a particular kind of "
19
+ "website or widget. Given the agent's goal and the list of playbooks, return the names of the playbooks the goal "
20
+ "clearly needs (at most 3), or an empty list when none fits. Never pick a playbook only because it could be loosely related."
21
+ )
22
+
23
+
24
+ def _clean(catalog: List[Dict[str, Any]]) -> List[Dict[str, str]]:
25
+ out: List[Dict[str, str]] = []
26
+ for s in catalog or []:
27
+ if not isinstance(s, dict):
28
+ continue
29
+ name = str(s.get("name") or "").strip()
30
+ body = str(s.get("body") or "").strip()
31
+ if name and body:
32
+ out.append({"name": name, "description": str(s.get("description") or "").strip(), "body": body})
33
+ return out
34
+
35
+
36
+ async def choose_skills(llm: Any, goal: str, catalog: List[Dict[str, Any]]) -> Tuple[List[str], Dict[str, int]]:
37
+ """Names from ``catalog`` that fit ``goal``, in catalogue order, and the tokens the choice took. Never raises."""
38
+ skills = _clean(catalog)
39
+ usage = {"input": 0, "output": 0}
40
+ if not skills:
41
+ return [], usage
42
+ try:
43
+ from browser_use.llm.messages import SystemMessage, UserMessage
44
+ from pydantic import BaseModel
45
+
46
+ class Choice(BaseModel):
47
+ skills: List[str]
48
+
49
+ listing = "\n".join(f"- {s['name']}: {s['description']}" for s in skills)
50
+ res = await asyncio.wait_for(
51
+ llm.ainvoke(
52
+ [SystemMessage(content=prompt_text("browser-skill-select", SELECT_SYSTEM)), UserMessage(content=f"Goal:\n{goal}\n\nPlaybooks:\n{listing}")],
53
+ output_format=Choice,
54
+ ),
55
+ timeout=SELECT_TIMEOUT_SECONDS,
56
+ )
57
+ u = getattr(res, "usage", None)
58
+ if u is not None:
59
+ usage = {"input": int(getattr(u, "prompt_tokens", 0) or 0), "output": int(getattr(u, "completion_tokens", 0) or 0)}
60
+ picked = {str(n).strip() for n in (getattr(res.completion, "skills", None) or [])}
61
+ except Exception as ex:
62
+ logger.warning("skill selection failed, running without skills: %s", ex)
63
+ return [], usage
64
+ return [s["name"] for s in skills if s["name"] in picked][:MAX_CHOSEN], usage
65
+
66
+
67
+ def skills_block(chosen: List[str], catalog: List[Dict[str, Any]], max_chars: int) -> Tuple[str, List[str], List[str]]:
68
+ """Prompt text for the chosen skills within ``max_chars``: (text, names included, names left out for size)."""
69
+ by_name = {s["name"]: s for s in _clean(catalog)}
70
+ parts: List[str] = []
71
+ used: List[str] = []
72
+ dropped: List[str] = []
73
+ size = 0
74
+ for name in chosen:
75
+ s = by_name.get(name)
76
+ if s is None:
77
+ continue
78
+ part = f"## Skill {name}\n\n{s['body']}"
79
+ if size + len(part) > max_chars:
80
+ dropped.append(name)
81
+ continue
82
+ parts.append(part)
83
+ used.append(name)
84
+ size += len(part) + 2
85
+ if not parts:
86
+ return "", used, dropped
87
+ head = "Skills for this run (follow them where they apply; the goal and the rules above come first):"
88
+ return head + "\n\n" + "\n\n".join(parts), used, dropped
@@ -0,0 +1,239 @@
1
+ """Summaries and result markdown built from the agent's history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import re
8
+ from collections import Counter
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ from .settings import _truthy_env
13
+
14
+
15
+ def _compact_url_journey(urls: List[str]) -> Optional[str]:
16
+ if not urls:
17
+ return None
18
+ seen: List[str] = []
19
+ for u in urls:
20
+ if u and u not in seen:
21
+ seen.append(u)
22
+ if not seen:
23
+ return None
24
+ if len(seen) == 1:
25
+ return f"**Page:** `{seen[0]}`"
26
+ a, b = seen[0], seen[-1]
27
+ if a == b:
28
+ return f"**Page:** `{a}`"
29
+ return f"**Journey:** `{a}` → … → `{b}` (`{len(seen)}` pages)"
30
+
31
+
32
+ def _fallback_summary_from_history(history: Any) -> str:
33
+ """When final_result() is empty, derive text from the trace."""
34
+ try:
35
+ for h in reversed(history.history or []):
36
+ if not h.model_output:
37
+ continue
38
+ for act in h.model_output.action:
39
+ try:
40
+ d = act.model_dump(exclude_none=True, mode="json")
41
+ except Exception:
42
+ continue
43
+ done = d.get("done") if isinstance(d, dict) else None
44
+ if isinstance(done, dict):
45
+ t = done.get("text")
46
+ if t and str(t).strip():
47
+ return str(t).strip()
48
+ except Exception:
49
+ pass
50
+ try:
51
+ exts = history.extracted_content()
52
+ except Exception:
53
+ exts = []
54
+ for block in reversed(exts or []):
55
+ txt = str(block).strip()
56
+ if len(txt) < 28:
57
+ continue
58
+ if txt.lower().startswith(("clicked ", "typed ", "waited ", "🔗")):
59
+ continue
60
+ return txt[:8000]
61
+ try:
62
+ if history.history:
63
+ mo = history.history[-1].model_output
64
+ if mo:
65
+ joined = " ".join(str(p) for p in (mo.memory, mo.next_goal, mo.evaluation_previous_goal) if p).strip()
66
+ if joined:
67
+ return joined[:4000]
68
+ except Exception:
69
+ pass
70
+ return ""
71
+
72
+
73
+ def _error_summary_from_history(history: Any) -> str:
74
+ """When the model or transport fails repeatedly, surface a clear message instead of an empty result."""
75
+ try:
76
+ errs = [str(e).strip() for e in history.errors() if e and str(e).strip()]
77
+ if not errs:
78
+ return ""
79
+ counts = Counter(errs)
80
+ distinct = list(counts.keys())
81
+ n = len(errs)
82
+ if len(distinct) == 1:
83
+ msg = distinct[0]
84
+ if n > 1:
85
+ return (
86
+ f"Run stopped after {n} identical errors: {msg}. "
87
+ "This usually means the LLM provider could not be reached or rejected the request — "
88
+ "check the profile's credentials (its API key; for auth: entra, that the identity has the "
89
+ "Cognitive Services OpenAI User role on the resource), endpoint, network, and quotas."
90
+ )
91
+ return f"Run stopped: {msg}. Verify LLM configuration and connectivity if this is a connection or auth error."
92
+ return (
93
+ f"Run stopped after {n} errors ({len(distinct)} distinct). Last: {distinct[-1]}. "
94
+ "Review LLM provider settings and the error trace."
95
+ )
96
+ except Exception:
97
+ return ""
98
+
99
+
100
+ def _primary_summary(history: Any) -> str:
101
+ try:
102
+ fr = history.final_result()
103
+ if fr and str(fr).strip():
104
+ return str(fr).strip()
105
+ except Exception:
106
+ pass
107
+ return _fallback_summary_from_history(history) or _error_summary_from_history(history)
108
+
109
+
110
+ def _failure_trace_markdown(history: Any) -> str:
111
+ lines: List[str] = ["### Error trace", ""]
112
+ try:
113
+ errs = [str(e).strip() for e in history.errors() if e and str(e).strip()]
114
+ if errs:
115
+ for msg, cnt in Counter(errs).most_common(12):
116
+ lines.append(f"- `{msg}`" + (f" (×{cnt})" if cnt > 1 else ""))
117
+ lines.append("")
118
+ except Exception:
119
+ pass
120
+ try:
121
+ steps = history.agent_steps()
122
+ if steps:
123
+ lines.append("### Recent agent steps (abbrev.)")
124
+ lines.append("")
125
+ for s in steps[-8:]:
126
+ chunk = str(s).strip()
127
+ if len(chunk) > 1200:
128
+ chunk = chunk[:1100] + "…"
129
+ lines.extend(["```", chunk, "```", ""])
130
+ except Exception:
131
+ pass
132
+ return "\n".join(lines).strip()
133
+
134
+
135
+ def _failure_screenshot_asset(history: Any) -> Optional[Tuple[str, bytes]]:
136
+ """(extension, bytes) for a real PNG/JPEG of the last state, or None."""
137
+ try:
138
+ fn = getattr(history, "screenshots", None)
139
+ if not callable(fn):
140
+ return None
141
+ shots = [s for s in (fn() or []) if s]
142
+ if not shots:
143
+ return None
144
+ last = shots[-1]
145
+ raw_bytes: Optional[bytes] = None
146
+ b64_guess: Optional[str] = None
147
+ if isinstance(last, str):
148
+ if last.startswith("data:image") and "," in last:
149
+ b64_guess = last.split(",", 1)[-1].strip()
150
+ elif len(last) > 80 and re.match(r"^[A-Za-z0-9+/=\s]+$", last[: min(200, len(last))]):
151
+ b64_guess = re.sub(r"\s+", "", last)
152
+ elif Path(last).is_file():
153
+ raw_bytes = Path(last).read_bytes()
154
+ elif isinstance(last, (bytes, bytearray)):
155
+ raw_bytes = bytes(last)
156
+ if raw_bytes is None and b64_guess:
157
+ b64_guess += "=" * ((-len(b64_guess)) % 4)
158
+ try:
159
+ raw_bytes = base64.b64decode(b64_guess, validate=True)
160
+ except Exception:
161
+ raw_bytes = None
162
+ if not raw_bytes or len(raw_bytes) < 64:
163
+ return None
164
+ if raw_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
165
+ return ("png", raw_bytes)
166
+ if raw_bytes.startswith(b"\xff\xd8\xff"):
167
+ return ("jpg", raw_bytes)
168
+ except Exception:
169
+ pass
170
+ return None
171
+
172
+
173
+ def build_browser_result_markdown(history: Any, checks: Optional[List[Dict[str, Any]]] = None) -> str:
174
+ """Human-readable markdown: the agent's own summary, the checks it recorded, compact URL line."""
175
+ verbose_md = _truthy_env("BROWSER_USE_VERBOSE_MARKDOWN")
176
+ summary_str = _primary_summary(history)
177
+
178
+ lines: List[str] = ["## Result", "", summary_str or "_Task finished with no summary text._", ""]
179
+
180
+ try:
181
+ ext = history.extracted_content()
182
+ except Exception:
183
+ ext = []
184
+
185
+ if checks:
186
+ lines.extend(["### Checks", ""])
187
+ lines += [f"- {c.get('expected') or c.get('text')}: \"{c.get('text')}\"" for c in checks]
188
+ lines.append("")
189
+
190
+ try:
191
+ journey = _compact_url_journey([u for u in (history.urls() or []) if u])
192
+ except Exception:
193
+ journey = None
194
+ if journey:
195
+ lines.extend([journey, ""])
196
+
197
+ try:
198
+ n_hist = len(history.history or [])
199
+ except Exception:
200
+ n_hist = 0
201
+ if n_hist:
202
+ lines.extend(["### Run metadata", "", f"- **LLM steps in trace:** {n_hist}", ""])
203
+
204
+ if verbose_md:
205
+ if ext:
206
+ lines.extend(["### All extractions (verbose)", ""])
207
+ for i, block in enumerate(ext, 1):
208
+ txt = str(block).strip() if block else ""
209
+ if txt:
210
+ lines.extend([f"_{i}._ {txt}", ""])
211
+ try:
212
+ ah = history.action_history()
213
+ except Exception:
214
+ ah = []
215
+ if ah:
216
+ lines.extend(["### Actions per step (verbose)", "", "| Step | Action |", "|------|--------|"])
217
+ for si, step_actions in enumerate(ah, 1):
218
+ for a in step_actions:
219
+ if not isinstance(a, dict):
220
+ continue
221
+ slim = {k: v for k, v in a.items() if k != "interacted_element" and v is not None}
222
+ cell = json.dumps(slim, ensure_ascii=False, default=str)[:450].replace("|", "\\|")
223
+ lines.append(f"| {si} | `{cell}` |")
224
+ lines.append("")
225
+
226
+ out = "\n".join(lines).strip()
227
+ return out or summary_str or "_No structured details were captured._"
228
+
229
+
230
+ def judge_from_history(history: Any) -> Optional[Dict[str, Any]]:
231
+ """The library judge's verdict on the finished run, or None when the judge did not run."""
232
+ try:
233
+ j = history.judgement()
234
+ except Exception:
235
+ return None
236
+ if not isinstance(j, dict) or not isinstance(j.get("verdict"), bool):
237
+ return None
238
+ keep = ("verdict", "reasoning", "failure_reason", "impossible_task", "reached_captcha")
239
+ return {k: j[k] for k in keep if j.get(k) not in (None, "")}
@@ -0,0 +1,81 @@
1
+ """Start URLs and the allowed-hosts list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from typing import List, Optional
8
+ from urllib.parse import urlparse
9
+
10
+
11
+ def _parse_allowed_domains(explicit: Optional[List[str]] = None) -> Optional[List[str]]:
12
+ parts = [p.strip().lower() for p in (explicit or []) if p and p.strip()]
13
+ if parts:
14
+ return parts
15
+ raw = (os.getenv("BROWSER_USE_ALLOWED_HOSTS") or "").strip()
16
+ if not raw:
17
+ return None
18
+ parts = [p.strip().lower() for p in raw.split(",") if p.strip()]
19
+ return parts or None
20
+
21
+
22
+ def _extract_urls(text: str) -> List[str]:
23
+ return re.findall(r"https?://[^\s<>\"']+", text or "")
24
+
25
+
26
+ def _hostname_allowed(hostname: str, allowed: Optional[List[str]]) -> bool:
27
+ if not allowed:
28
+ return True
29
+ h = (hostname or "").lower().rstrip(".")
30
+ for pattern in allowed:
31
+ p = pattern.strip().lower()
32
+ if not p:
33
+ continue
34
+ if p.startswith("*."):
35
+ suffix = p[1:]
36
+ if h.endswith(suffix) or h == p[2:]:
37
+ return True
38
+ elif h == p or h.endswith("." + p):
39
+ return True
40
+ return False
41
+
42
+
43
+ def _allowed_hosts_violation_for_url(url: str, allowed: Optional[List[str]]) -> Optional[str]:
44
+ """Return an error message if this URL's host is outside the allow-list (when set)."""
45
+ if not allowed:
46
+ return None
47
+ try:
48
+ host = urlparse(url).hostname or ""
49
+ except Exception:
50
+ return f"Invalid URL in task: {url}"
51
+ if not host:
52
+ return None
53
+ if not _hostname_allowed(host, allowed):
54
+ return f"Host not allowed by allowed_hosts: {host}"
55
+ return None
56
+
57
+
58
+ def _validate_goal_urls_against_allowlist(goal: str, allowed: Optional[List[str]]) -> Optional[str]:
59
+ for url in _extract_urls(goal):
60
+ err = _allowed_hosts_violation_for_url(url, allowed)
61
+ if err:
62
+ return err
63
+ return None
64
+
65
+
66
+ def normalize_http_base_url(raw: Optional[str]) -> Optional[str]:
67
+ """Trim and ensure an http(s) scheme. None if empty, unparsable, or missing a host."""
68
+ if raw is None:
69
+ return None
70
+ u = str(raw).strip()
71
+ if not u:
72
+ return None
73
+ if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", u):
74
+ u = "https://" + u.lstrip("/")
75
+ try:
76
+ parsed = urlparse(u)
77
+ except Exception:
78
+ return None
79
+ if not parsed.scheme or not parsed.netloc:
80
+ return None
81
+ return u