@capagents/forgeqe 0.0.0-stage → 1.0.0
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.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- 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
|