@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,195 @@
|
|
|
1
|
+
"""The browser agent's model: built from a ForgeQE llms.json profile, timed, and its token usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("forgeqe_engine.service")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
AZURE_API_VERSION_DEFAULT = "2024-05-01-preview"
|
|
15
|
+
|
|
16
|
+
# Microsoft Entra ID scope for Azure OpenAI and Azure AI Foundry; a gateway such as API Management sets its own.
|
|
17
|
+
ENTRA_SCOPE_DEFAULT = "https://cognitiveservices.azure.com/.default"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_AZURE_ENDPOINT_PATH_SUFFIXES = (
|
|
21
|
+
"/openai/v1/chat/completions",
|
|
22
|
+
"/openai/deployments",
|
|
23
|
+
"/chat/completions",
|
|
24
|
+
"/openai/v1",
|
|
25
|
+
"/models/chat/completions",
|
|
26
|
+
"/models",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_azure_endpoint(endpoint: Optional[str]) -> str:
|
|
31
|
+
"""Strip API path segments so AzureOpenAI gets a base resource URL only."""
|
|
32
|
+
url = (endpoint or "").strip()
|
|
33
|
+
if not url:
|
|
34
|
+
return ""
|
|
35
|
+
if "?" in url:
|
|
36
|
+
url = url.split("?", 1)[0].strip()
|
|
37
|
+
url = url.rstrip("/")
|
|
38
|
+
lower = url.lower()
|
|
39
|
+
for suffix in _AZURE_ENDPOINT_PATH_SUFFIXES:
|
|
40
|
+
if lower.endswith(suffix):
|
|
41
|
+
url = url[: -len(suffix)].rstrip("/")
|
|
42
|
+
lower = url.lower()
|
|
43
|
+
return url
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_api_key(cfg: Dict[str, Any]) -> str:
|
|
47
|
+
key = str(cfg.get("api_key") or cfg.get("apiKey") or "").strip()
|
|
48
|
+
if key:
|
|
49
|
+
return key
|
|
50
|
+
env_name = str(cfg.get("api_key_env") or "").strip()
|
|
51
|
+
if env_name and os.getenv(env_name):
|
|
52
|
+
return str(os.getenv(env_name))
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def uses_entra(cfg: Dict[str, Any]) -> bool:
|
|
57
|
+
return str(cfg.get("auth") or "key").strip().lower() == "entra"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def entra_scope(cfg: Dict[str, Any]) -> str:
|
|
61
|
+
return str(cfg.get("token_scope") or cfg.get("tokenScope") or ENTRA_SCOPE_DEFAULT).strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def entra_credential(cfg: Dict[str, Any]):
|
|
65
|
+
"""
|
|
66
|
+
Microsoft Entra ID credential for an `auth: entra` profile: managed identity (user-assigned when the profile names
|
|
67
|
+
its client id), workload identity, a service principal in AZURE_* variables, or the Azure CLI's sign-in, whichever
|
|
68
|
+
this machine has. No key is read or stored.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
from azure.identity import DefaultAzureCredential
|
|
72
|
+
except ImportError as e:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"auth: entra needs the azure-identity package in the engine's Python. Run forgeqe setup, "
|
|
75
|
+
"or pip install azure-identity into FORGEQE_PYTHON."
|
|
76
|
+
) from e
|
|
77
|
+
client_id = str(cfg.get("managed_identity_client_id") or cfg.get("managedIdentityClientId") or "").strip()
|
|
78
|
+
return DefaultAzureCredential(managed_identity_client_id=client_id) if client_id else DefaultAzureCredential()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def entra_token_provider(cfg: Dict[str, Any]):
|
|
82
|
+
"""A callable returning a current bearer token; azure-identity caches it and renews it before it expires."""
|
|
83
|
+
from azure.identity import get_bearer_token_provider
|
|
84
|
+
|
|
85
|
+
return get_bearer_token_provider(entra_credential(cfg), entra_scope(cfg))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _bearer_client(token: Any):
|
|
89
|
+
"""An HTTP client that puts a current Entra ID token in each request's Authorization header."""
|
|
90
|
+
import httpx
|
|
91
|
+
|
|
92
|
+
class _Bearer(httpx.Auth):
|
|
93
|
+
def auth_flow(self, request: httpx.Request):
|
|
94
|
+
request.headers["Authorization"] = f"Bearer {token()}"
|
|
95
|
+
yield request
|
|
96
|
+
|
|
97
|
+
return httpx.AsyncClient(auth=_Bearer(), limits=httpx.Limits(max_connections=20, max_keepalive_connections=6))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _build_llm(llm_config: Optional[Dict[str, Any]]):
|
|
101
|
+
"""Construct a browser_use chat model from a ForgeQE llms.json profile."""
|
|
102
|
+
cfg: Dict[str, Any] = dict(llm_config) if llm_config else {}
|
|
103
|
+
provider = (cfg.get("provider") or "azure").lower()
|
|
104
|
+
entra = uses_entra(cfg)
|
|
105
|
+
if entra and provider not in ("azure", "openai_compatible"):
|
|
106
|
+
raise ValueError(f"auth: entra works with the azure and openai_compatible providers, not {provider}.")
|
|
107
|
+
api_key = "" if entra else _resolve_api_key(cfg)
|
|
108
|
+
|
|
109
|
+
if provider == "ollama":
|
|
110
|
+
from browser_use.llm.ollama.chat import ChatOllama
|
|
111
|
+
|
|
112
|
+
host = (cfg.get("endpoint") or cfg.get("apiEndpoint") or "http://localhost:11434").rstrip("/")
|
|
113
|
+
# llms.json often holds the OpenAI-compatible /v1 URL; the native Ollama client wants the bare host.
|
|
114
|
+
host = re.sub(r"/v1$", "", host)
|
|
115
|
+
return ChatOllama(model=str(cfg.get("model") or "llama3.2"), host=host)
|
|
116
|
+
|
|
117
|
+
if provider == "azure":
|
|
118
|
+
from browser_use.llm.azure.chat import ChatAzureOpenAI
|
|
119
|
+
|
|
120
|
+
endpoint = normalize_azure_endpoint(cfg.get("endpoint") or cfg.get("apiEndpoint") or "")
|
|
121
|
+
deployment = cfg.get("deployment") or cfg.get("deploymentName") or cfg.get("model")
|
|
122
|
+
api_version = cfg.get("api_version") or cfg.get("apiVersion") or AZURE_API_VERSION_DEFAULT
|
|
123
|
+
if not endpoint or not deployment:
|
|
124
|
+
raise ValueError("Azure OpenAI needs an endpoint and a deployment: check them in llms.json.")
|
|
125
|
+
dep = str(deployment)
|
|
126
|
+
common: Dict[str, Any] = {"model": dep, "azure_deployment": dep, "azure_endpoint": endpoint, "api_version": str(api_version)}
|
|
127
|
+
if entra:
|
|
128
|
+
return ChatAzureOpenAI(**common, azure_ad_token_provider=entra_token_provider(cfg))
|
|
129
|
+
if not api_key:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"Azure OpenAI needs an API key: set the profile's api_key_env variable (see llms.json), "
|
|
132
|
+
"or set auth: entra to sign in with Microsoft Entra ID instead."
|
|
133
|
+
)
|
|
134
|
+
return ChatAzureOpenAI(**common, api_key=api_key)
|
|
135
|
+
|
|
136
|
+
if provider in ("openai", "openai_compatible"):
|
|
137
|
+
from browser_use.llm.openai.chat import ChatOpenAI
|
|
138
|
+
|
|
139
|
+
kw: Dict[str, Any] = {"model": str(cfg.get("model") or "gpt-4o")}
|
|
140
|
+
if provider == "openai_compatible":
|
|
141
|
+
base = str(cfg.get("endpoint") or "").strip()
|
|
142
|
+
if not base:
|
|
143
|
+
raise ValueError("openai_compatible profiles need an endpoint (base URL).")
|
|
144
|
+
kw["base_url"] = base
|
|
145
|
+
if entra:
|
|
146
|
+
# The gateway takes an Entra ID bearer token; the client asks for a fresh one on every request.
|
|
147
|
+
kw["api_key"] = "entra"
|
|
148
|
+
kw["http_client"] = _bearer_client(entra_token_provider(cfg))
|
|
149
|
+
return ChatOpenAI(**kw)
|
|
150
|
+
kw["api_key"] = api_key or "local"
|
|
151
|
+
else:
|
|
152
|
+
if not api_key:
|
|
153
|
+
raise ValueError("OpenAI needs an API key: set OPENAI_API_KEY or the profile's api_key_env.")
|
|
154
|
+
kw["api_key"] = api_key
|
|
155
|
+
return ChatOpenAI(**kw)
|
|
156
|
+
|
|
157
|
+
raise ValueError(f"Unsupported LLM provider for the engine: {provider}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _time_llm_calls(llm: Any, calls: List[Tuple[float, float]]) -> None:
|
|
161
|
+
"""Record when each model call started and ended, so the report can split step time into model and browser."""
|
|
162
|
+
inner = getattr(llm, "ainvoke", None)
|
|
163
|
+
if inner is None:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
async def timed(*args: Any, **kwargs: Any) -> Any:
|
|
167
|
+
started = time.time()
|
|
168
|
+
try:
|
|
169
|
+
return await inner(*args, **kwargs)
|
|
170
|
+
finally:
|
|
171
|
+
calls.append((started, time.time()))
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
object.__setattr__(llm, "ainvoke", timed)
|
|
175
|
+
except Exception:
|
|
176
|
+
logger.debug("model call timing not available for %s", type(llm).__name__)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _usage_from_history(history: Any) -> Dict[str, int]:
|
|
180
|
+
try:
|
|
181
|
+
u = getattr(history, "usage", None)
|
|
182
|
+
if u is not None:
|
|
183
|
+
return {"input": int(u.total_prompt_tokens or 0), "output": int(u.total_completion_tokens or 0), "cached": int(getattr(u, "total_prompt_cached_tokens", 0) or 0)}
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
return {"input": 0, "output": 0}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def _agent_usage(agent: Any) -> Dict[str, int]:
|
|
190
|
+
"""Tokens used so far; history.usage is only filled in when run() finishes, not when the run is stopped or times out."""
|
|
191
|
+
try:
|
|
192
|
+
u = await agent.token_cost_service.get_usage_summary()
|
|
193
|
+
return {"input": int(u.total_prompt_tokens or 0), "output": int(u.total_completion_tokens or 0), "cached": int(u.total_prompt_cached_tokens or 0)}
|
|
194
|
+
except Exception:
|
|
195
|
+
return _usage_from_history(getattr(agent, "history", None))
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Saved logins for the browser agent.
|
|
3
|
+
|
|
4
|
+
Credentials never reach the model: each login becomes the library's ``sensitive_data`` scoped to its site, so the
|
|
5
|
+
agent only sees placeholders such as ``<secret>shop_password</secret>`` and the library types the real value.
|
|
6
|
+
A placeholder ending in ``bu_2fa_code`` holds an authenticator (TOTP) secret; the library types a fresh code.
|
|
7
|
+
Cookies and local storage for each login's site are kept in a session file between runs.
|
|
8
|
+
Values come from the environment by name, or from the request's ``values`` for logins ForgeQE remembered for its session
|
|
9
|
+
(credentials the user gave in a message or test case); those arrive over stdin and are never logged.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field as dc_field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
FIELDS = (("username", "username_env", "text"), ("password", "password_env", "text"), ("bu_2fa_code", "totp_env", "totp"))
|
|
25
|
+
_TAG = re.compile(r"^\s*<secret>\s*([A-Za-z0-9_]+)\s*</secret>\s*$")
|
|
26
|
+
|
|
27
|
+
# placeholder -> ("text" | "totp", env var); set for the current run by use_logins().
|
|
28
|
+
_PLACEHOLDERS: Dict[str, Tuple[str, str]] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Login:
|
|
33
|
+
name: str
|
|
34
|
+
domains: List[str]
|
|
35
|
+
username_env: Optional[str] = None
|
|
36
|
+
password_env: Optional[str] = None
|
|
37
|
+
totp_env: Optional[str] = None
|
|
38
|
+
session_file: Optional[str] = None
|
|
39
|
+
values: Dict[str, str] = dc_field(default_factory=dict, repr=False)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Login":
|
|
43
|
+
name = re.sub(r"[^A-Za-z0-9_]", "_", str(d.get("name") or "login"))
|
|
44
|
+
domains = [str(x).strip() for x in (d.get("domains") or []) if str(x).strip()]
|
|
45
|
+
env = lambda k: (str(d[k]).strip() or None) if d.get(k) else None # noqa: E731
|
|
46
|
+
raw = d.get("values") if isinstance(d.get("values"), dict) else {}
|
|
47
|
+
values = {k: str(v) for k, v in raw.items() if k in ("username", "password", "totp") and isinstance(v, str) and v}
|
|
48
|
+
return cls(name, domains, env("username_env"), env("password_env"), env("totp_env"), env("session_file"), values)
|
|
49
|
+
|
|
50
|
+
def placeholder(self, field: str) -> str:
|
|
51
|
+
return f"{self.name}_{field}"
|
|
52
|
+
|
|
53
|
+
def hosts(self) -> List[str]:
|
|
54
|
+
out = []
|
|
55
|
+
for pattern in self.domains:
|
|
56
|
+
host = pattern.split("://", 1)[-1].split("/", 1)[0].split(":", 1)[0].lower()
|
|
57
|
+
out.append(host[2:] if host.startswith("*.") else host.lstrip("*"))
|
|
58
|
+
return [h for h in out if h]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_logins(raw: Any) -> List[Login]:
|
|
62
|
+
return [Login.from_dict(d) for d in (raw or []) if isinstance(d, dict) and d.get("domains")]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def use_logins(logins: List[Login]) -> Tuple[Dict[str, Dict[str, str]], List[str]]:
|
|
66
|
+
"""The library's sensitive_data ({domain pattern: {placeholder: value}}) and the env vars that are not set."""
|
|
67
|
+
_PLACEHOLDERS.clear()
|
|
68
|
+
data: Dict[str, Dict[str, str]] = {}
|
|
69
|
+
missing: List[str] = []
|
|
70
|
+
for login in logins:
|
|
71
|
+
secrets: Dict[str, str] = {}
|
|
72
|
+
for field, attr, kind in FIELDS:
|
|
73
|
+
env = getattr(login, attr)
|
|
74
|
+
if not env:
|
|
75
|
+
continue
|
|
76
|
+
given = login.values.get("totp" if kind == "totp" else field)
|
|
77
|
+
value = given if given else (os.environ.get(env) or "").strip()
|
|
78
|
+
if not value:
|
|
79
|
+
missing.append(env)
|
|
80
|
+
continue
|
|
81
|
+
if given:
|
|
82
|
+
# This process runs one browser task; replayed sign-in steps read the value by env name.
|
|
83
|
+
os.environ[env] = given
|
|
84
|
+
key = login.placeholder(field)
|
|
85
|
+
secrets[key] = value.replace(" ", "") if kind == "totp" else value
|
|
86
|
+
_PLACEHOLDERS[key] = (kind, env)
|
|
87
|
+
if secrets:
|
|
88
|
+
for pattern in login.domains:
|
|
89
|
+
data.setdefault(pattern, {}).update(secrets)
|
|
90
|
+
return data, missing
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def secret_ref(text: Any) -> Optional[Tuple[str, str]]:
|
|
94
|
+
"""("text" | "totp", env var) when an action's text is a login placeholder, tagged or bare."""
|
|
95
|
+
if not isinstance(text, str) or not _PLACEHOLDERS:
|
|
96
|
+
return None
|
|
97
|
+
m = _TAG.match(text)
|
|
98
|
+
key = m.group(1) if m else text.strip()
|
|
99
|
+
return _PLACEHOLDERS.get(key)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def text_envs() -> List[str]:
|
|
103
|
+
return [env for kind, env in _PLACEHOLDERS.values() if kind == "text"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def totp_code(secret: str) -> str:
|
|
107
|
+
import pyotp
|
|
108
|
+
|
|
109
|
+
return pyotp.TOTP(secret.replace(" ", ""), digits=6).now()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def prompt_block(logins: List[Login]) -> Optional[str]:
|
|
113
|
+
lines = []
|
|
114
|
+
for login in logins:
|
|
115
|
+
parts = [
|
|
116
|
+
f"{label} <secret>{login.placeholder(field)}</secret>"
|
|
117
|
+
for field, attr, label in (("username", "username_env", "username"), ("password", "password_env", "password"), ("bu_2fa_code", "totp_env", "one-time code"))
|
|
118
|
+
if getattr(login, attr) and login.placeholder(field) in _PLACEHOLDERS
|
|
119
|
+
]
|
|
120
|
+
if parts:
|
|
121
|
+
lines.append(f"- {login.name} on {', '.join(login.domains)}: {', '.join(parts)}")
|
|
122
|
+
if not lines:
|
|
123
|
+
return None
|
|
124
|
+
return "\n".join(
|
|
125
|
+
[
|
|
126
|
+
"## Saved logins",
|
|
127
|
+
"Use these only when the site asks you to sign in (a login form, or a page asking for an authenticator / 2FA / one-time code) and the task did not give other credentials. "
|
|
128
|
+
"Type the placeholder exactly as written, including the <secret> tags; the real value is filled in for you. "
|
|
129
|
+
"The one-time code placeholder produces a fresh code each time you type it. If the page already shows you signed in, carry on without signing in again.",
|
|
130
|
+
*lines,
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _host_matches(host: str, login_hosts: List[str]) -> bool:
|
|
136
|
+
host = host.lstrip(".").lower()
|
|
137
|
+
return any(host == h or h.endswith("." + host) or host.endswith("." + h) for h in login_hosts)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_sessions(logins: List[Login]) -> Optional[Dict[str, Any]]:
|
|
141
|
+
"""Cookies and local storage saved by earlier runs, merged into one storage state; None when there are none."""
|
|
142
|
+
cookies: List[Dict[str, Any]] = []
|
|
143
|
+
origins: List[Dict[str, Any]] = []
|
|
144
|
+
for login in logins:
|
|
145
|
+
if not login.session_file:
|
|
146
|
+
continue
|
|
147
|
+
file = Path(login.session_file)
|
|
148
|
+
if not file.is_file():
|
|
149
|
+
continue
|
|
150
|
+
try:
|
|
151
|
+
state = json.loads(file.read_text(encoding="utf-8"))
|
|
152
|
+
except Exception as ex:
|
|
153
|
+
logger.warning("login %s: session file unreadable (%s); signing in again", login.name, ex)
|
|
154
|
+
continue
|
|
155
|
+
cookies.extend(c for c in state.get("cookies") or [] if isinstance(c, dict))
|
|
156
|
+
origins.extend(o for o in state.get("origins") or [] if isinstance(o, dict))
|
|
157
|
+
if not cookies and not origins:
|
|
158
|
+
return None
|
|
159
|
+
return {"cookies": cookies, "origins": origins}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def split_sessions(state: Dict[str, Any], logins: List[Login]) -> Dict[str, Dict[str, Any]]:
|
|
163
|
+
"""The part of a browser storage state that belongs to each login's site, by login name."""
|
|
164
|
+
out: Dict[str, Dict[str, Any]] = {}
|
|
165
|
+
for login in logins:
|
|
166
|
+
if not login.session_file:
|
|
167
|
+
continue
|
|
168
|
+
hosts = login.hosts()
|
|
169
|
+
cookies = [c for c in state.get("cookies") or [] if _host_matches(str(c.get("domain") or ""), hosts)]
|
|
170
|
+
origins = [o for o in state.get("origins") or [] if _host_matches(urlparse(str(o.get("origin") or "")).hostname or "", hosts)]
|
|
171
|
+
if cookies or origins:
|
|
172
|
+
out[login.name] = {"cookies": cookies, "origins": origins}
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def write_session(file: str, state: Dict[str, Any]) -> None:
|
|
177
|
+
path = Path(file)
|
|
178
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
179
|
+
ignore = path.parent / ".gitignore"
|
|
180
|
+
if not ignore.exists():
|
|
181
|
+
ignore.write_text("# Saved browser sessions (cookies); never commit them.\n*\n", encoding="utf-8")
|
|
182
|
+
tmp = path.with_suffix(f".{os.getpid()}.tmp")
|
|
183
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
184
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
185
|
+
json.dump(state, fh, indent=2)
|
|
186
|
+
os.replace(tmp, path)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def interactive_login(payload: Dict[str, Any], emit: Any, finished: Any) -> Dict[str, Any]:
|
|
190
|
+
"""
|
|
191
|
+
Open a visible browser on the login's page with any saved session, let a person sign in, and save the session
|
|
192
|
+
when `finished` is set by a "save" line on stdin.
|
|
193
|
+
"""
|
|
194
|
+
from browser_use import Browser
|
|
195
|
+
|
|
196
|
+
from . import brand
|
|
197
|
+
from .service import _shutdown_browser_instance, local_browser_launch_kwargs
|
|
198
|
+
|
|
199
|
+
logins = parse_logins(payload.get("logins"))
|
|
200
|
+
url = str(payload.get("url") or "").strip()
|
|
201
|
+
if not logins or not url:
|
|
202
|
+
return {"success": False, "error": "A login with a url is required."}
|
|
203
|
+
brand.install()
|
|
204
|
+
kw = local_browser_launch_kwargs(headless=False)
|
|
205
|
+
stored = load_sessions(logins)
|
|
206
|
+
if stored:
|
|
207
|
+
kw["storage_state"] = stored
|
|
208
|
+
browser = Browser(**kw)
|
|
209
|
+
overlay = brand.Overlay(browser, lock=False) if payload.get("overlay") is not False else None
|
|
210
|
+
try:
|
|
211
|
+
await browser.start()
|
|
212
|
+
await browser._cdp_navigate(url)
|
|
213
|
+
if overlay is not None:
|
|
214
|
+
await overlay.waiting(f"Sign in to {logins[0].name} here, then press Enter in the terminal to save the session")
|
|
215
|
+
emit({"type": "log", "level": "info", "text": f"Browser open at {url}"})
|
|
216
|
+
await finished.wait()
|
|
217
|
+
if payload.get("cancelled"):
|
|
218
|
+
return {"success": False, "error": "cancelled"}
|
|
219
|
+
saved = await save_sessions(browser, logins, {})
|
|
220
|
+
if not saved:
|
|
221
|
+
return {"success": False, "error": f"No cookies or local storage for {', '.join(logins[0].domains)} were found; nothing was saved."}
|
|
222
|
+
return {"success": True, "saved": saved, "summary": f"Session saved for {', '.join(saved)}"}
|
|
223
|
+
finally:
|
|
224
|
+
await _shutdown_browser_instance(browser)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def save_sessions(browser: Any, logins: List[Login], written: Dict[str, str]) -> List[str]:
|
|
228
|
+
"""
|
|
229
|
+
Write each login's cookies and local storage from the live browser when they changed since the last write
|
|
230
|
+
(`written` holds what was last written, by login). Returns the logins written this time.
|
|
231
|
+
"""
|
|
232
|
+
if not any(l.session_file for l in logins):
|
|
233
|
+
return []
|
|
234
|
+
try:
|
|
235
|
+
state = await browser._cdp_get_storage_state()
|
|
236
|
+
except Exception as ex:
|
|
237
|
+
logger.debug("could not read the browser's cookies to save logins: %s", ex)
|
|
238
|
+
return []
|
|
239
|
+
saved = []
|
|
240
|
+
for name, part in split_sessions(dict(state or {}), logins).items():
|
|
241
|
+
body = json.dumps(part, sort_keys=True)
|
|
242
|
+
if written.get(name) == body:
|
|
243
|
+
continue
|
|
244
|
+
login = next(l for l in logins if l.name == name)
|
|
245
|
+
try:
|
|
246
|
+
write_session(str(login.session_file), part)
|
|
247
|
+
written[name] = body
|
|
248
|
+
saved.append(name)
|
|
249
|
+
except Exception as ex:
|
|
250
|
+
logger.warning("login %s: could not save the session: %s", name, ex)
|
|
251
|
+
return saved
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Progress checks, the repeated-control loop breaker and its nudges, and where a failed run resumes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# max_steps is a checkpoint: a run still making progress there goes on, up to (1 + STEP_EXTENSIONS) x max_steps.
|
|
12
|
+
STEP_EXTENSIONS = 2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PROGRESS_WINDOW = 10
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PROGRESS_NEEDED = 3
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def agent_steps(history_items: Any) -> List[Any]:
|
|
22
|
+
"""The history items of the agent's own steps, numbered from 1; the library's opening navigation is step 0."""
|
|
23
|
+
return [h for h in (history_items or []) if getattr(getattr(h, "metadata", None), "step_number", None) != 0]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def step_outcomes(history_items: Any) -> List[bool]:
|
|
27
|
+
"""Per agent step: its actions ran without an error and the agent's own evaluation in the next step did not call it a failure."""
|
|
28
|
+
items = agent_steps(history_items)
|
|
29
|
+
out: List[bool] = []
|
|
30
|
+
for i, h in enumerate(items):
|
|
31
|
+
results = getattr(h, "result", None) or []
|
|
32
|
+
ok = bool(results) and not any(getattr(r, "error", None) for r in results)
|
|
33
|
+
if ok and i + 1 < len(items):
|
|
34
|
+
verdict = str(getattr(getattr(items[i + 1], "model_output", None), "evaluation_previous_goal", "") or "").lower()
|
|
35
|
+
if "fail" in verdict and "success" not in verdict:
|
|
36
|
+
ok = False
|
|
37
|
+
out.append(ok)
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def making_progress(outcomes: List[bool], window: int = PROGRESS_WINDOW, needed: int = PROGRESS_NEEDED) -> bool:
|
|
42
|
+
recent = outcomes[-window:]
|
|
43
|
+
return bool(recent) and sum(recent) >= min(needed, len(recent))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resume_point(outcomes: List[bool], *, loop_from: Optional[int] = None, stalled: bool = False) -> int:
|
|
47
|
+
"""
|
|
48
|
+
The agent step (1-based) where a failed run went wrong; the steps before it are the ones worth replaying.
|
|
49
|
+
|
|
50
|
+
A repeated-control loop went wrong where the repeats began, a stalled run where its last progress window began,
|
|
51
|
+
and any other failure at its trailing streak of failed steps, or at its last step when that streak is empty.
|
|
52
|
+
"""
|
|
53
|
+
n = len(outcomes)
|
|
54
|
+
if loop_from:
|
|
55
|
+
return max(1, loop_from)
|
|
56
|
+
if stalled:
|
|
57
|
+
return max(1, n - PROGRESS_WINDOW + 1)
|
|
58
|
+
i = n
|
|
59
|
+
while i > 0 and not outcomes[i - 1]:
|
|
60
|
+
i -= 1
|
|
61
|
+
return i + 1 if i < n else max(1, n)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resume_steps(steps: List[Dict[str, Any]], step_of: List[int], failed_at: int) -> List[Dict[str, Any]]:
|
|
65
|
+
"""Recorded steps from before `failed_at` (replayed ones count as step 0); empty when none of them does more than open or wait."""
|
|
66
|
+
kept = [s for s, n in zip(steps, step_of) if n < failed_at]
|
|
67
|
+
if not any(s.get("action") not in ("navigate", "wait", "scroll") for s in kept):
|
|
68
|
+
return []
|
|
69
|
+
return kept
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_LOOP_PRONE_ACTION_KEYS = frozenset({"input", "click", "search", "type", "select"})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_CONTROL_LOOP_GOAL_KEYWORDS = ("search", "select", "outlet", "autocomplete", "combobox", "filter", "lookup", "pick", "choose")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _resolve_browser_control_loop_max_retries() -> int:
|
|
79
|
+
raw = (os.getenv("BROWSER_USE_CONTROL_LOOP_MAX_RETRIES") or "").strip()
|
|
80
|
+
if raw.isdigit():
|
|
81
|
+
return max(2, int(raw))
|
|
82
|
+
return 4
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_browser_control_loop_window() -> int:
|
|
86
|
+
raw = (os.getenv("BROWSER_USE_CONTROL_LOOP_WINDOW") or "").strip()
|
|
87
|
+
if raw.isdigit():
|
|
88
|
+
return max(4, int(raw))
|
|
89
|
+
return 8
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _normalize_snippet(s: str) -> str:
|
|
93
|
+
return re.sub(r"\s+", " ", (s or "").strip().lower())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _action_fingerprint(action_name: str, params: Any) -> Optional[str]:
|
|
97
|
+
if action_name not in _LOOP_PRONE_ACTION_KEYS:
|
|
98
|
+
return None
|
|
99
|
+
if not isinstance(params, dict):
|
|
100
|
+
params = {}
|
|
101
|
+
if action_name in ("input", "type"):
|
|
102
|
+
text = str(params.get("text", "")).strip().lower()
|
|
103
|
+
return f"input|{params.get('index')}|{text}"
|
|
104
|
+
if action_name == "click":
|
|
105
|
+
return f"click|{params.get('index')}"
|
|
106
|
+
if action_name == "search":
|
|
107
|
+
query = str(params.get("query", "")).strip().lower()
|
|
108
|
+
tokens = sorted(set(re.sub(r"[^\w\s]", " ", query).split()))
|
|
109
|
+
return f"search|{'|'.join(tokens)}"
|
|
110
|
+
text = str(params.get("text", params.get("label", ""))).strip().lower()
|
|
111
|
+
return f"select|{params.get('index')}|{text}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _step_action_lines(step: Dict[str, Any]) -> List[Tuple[str, str]]:
|
|
115
|
+
"""(fingerprint, action line) for the search/select/input actions of one browser step, plus its goal when it is one."""
|
|
116
|
+
out: List[Tuple[str, str]] = []
|
|
117
|
+
lines = list(step.get("action_lines") or [])
|
|
118
|
+
for i, action in enumerate(step.get("actions") or []):
|
|
119
|
+
if not isinstance(action, dict):
|
|
120
|
+
continue
|
|
121
|
+
for action_name, params in action.items():
|
|
122
|
+
if action_name in ("interacted_element", "result"):
|
|
123
|
+
continue
|
|
124
|
+
fp = _action_fingerprint(action_name, params)
|
|
125
|
+
if fp:
|
|
126
|
+
out.append((fp, str(lines[i]) if i < len(lines) else action_name))
|
|
127
|
+
|
|
128
|
+
goal = (step.get("next_goal") or step.get("memory") or "").strip().lower()
|
|
129
|
+
if goal and any(k in goal for k in _CONTROL_LOOP_GOAL_KEYWORDS):
|
|
130
|
+
out.append((f"goal|{_normalize_snippet(goal)[:120]}", (step.get("next_goal") or step.get("memory") or "").strip()))
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _step_action_fingerprints(step: Dict[str, Any]) -> List[str]:
|
|
135
|
+
"""Fingerprints for search/select/input loops within a single browser step."""
|
|
136
|
+
return [fp for fp, _ in _step_action_lines(step)]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _loop_start(collected_steps: List[Dict[str, Any]], fingerprint: str, window: int) -> Tuple[int, str]:
|
|
140
|
+
"""1-based index of the first step in the recent window that repeated `fingerprint`, and that action's line."""
|
|
141
|
+
start = max(0, len(collected_steps) - window)
|
|
142
|
+
first, line = len(collected_steps), ""
|
|
143
|
+
for i in range(start, len(collected_steps)):
|
|
144
|
+
for fp, text in _step_action_lines(collected_steps[i]):
|
|
145
|
+
if fp == fingerprint:
|
|
146
|
+
first = min(first, i + 1)
|
|
147
|
+
line = text
|
|
148
|
+
return first, line
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _loop_nudge_message(*, line: str, count: int, window: int) -> str:
|
|
152
|
+
what = f"`{line}`" if line else "the same action"
|
|
153
|
+
return (
|
|
154
|
+
f"ForgeQE: you have done {what} {count} times in the last {window} steps and the page is still not where you want it. "
|
|
155
|
+
"Doing it again will not help. Before acting, look at the current page and work out why the earlier attempts did not stick: "
|
|
156
|
+
"clicking one control can reset another (for example, a trip-type toggle can clear a date you just picked), a picker may need "
|
|
157
|
+
"a confirm or close, the value may already be set, or the element you want may be a different one. Then take a different approach. "
|
|
158
|
+
"If there is no way forward, call done with success false and describe the blocker."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _control_loop_exceeded(
|
|
163
|
+
collected_steps: List[Dict[str, Any]], *, max_retries: int, window: int
|
|
164
|
+
) -> Tuple[bool, Optional[str], int]:
|
|
165
|
+
"""True when the same search/select action repeats too often in the recent step window."""
|
|
166
|
+
if not collected_steps or max_retries < 2:
|
|
167
|
+
return False, None, 0
|
|
168
|
+
counts: Counter[str] = Counter()
|
|
169
|
+
for step in collected_steps[-window:]:
|
|
170
|
+
for fp in _step_action_fingerprints(step):
|
|
171
|
+
counts[fp] += 1
|
|
172
|
+
if not counts:
|
|
173
|
+
return False, None, 0
|
|
174
|
+
fp, count = counts.most_common(1)[0]
|
|
175
|
+
if count >= max_retries:
|
|
176
|
+
return True, fp, count
|
|
177
|
+
return False, None, 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _control_loop_break_message(*, count: int, max_retries: int, fingerprint: Optional[str], nudged: bool = False) -> str:
|
|
181
|
+
detail = ""
|
|
182
|
+
if fingerprint:
|
|
183
|
+
if fingerprint.startswith("input|"):
|
|
184
|
+
parts = fingerprint.split("|", 2)
|
|
185
|
+
detail = f" (repeated input '{parts[2]}')" if len(parts) >= 3 and parts[2] else " (repeated input on the same field)"
|
|
186
|
+
elif fingerprint.startswith("search|"):
|
|
187
|
+
detail = " (repeated search)"
|
|
188
|
+
elif fingerprint.startswith("select|"):
|
|
189
|
+
detail = " (repeated select)"
|
|
190
|
+
elif fingerprint.startswith("goal|"):
|
|
191
|
+
detail = " (repeated search/select goal)"
|
|
192
|
+
return (
|
|
193
|
+
f"Stopped after {count} repeated attempts on the same control (limit {max_retries}){detail}"
|
|
194
|
+
+ (", after the agent had already been told to change approach. " if nudged else ". ")
|
|
195
|
+
+ "Try a different selector, wait longer for results, or complete this step manually."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _install_nudges(agent: Any, queue: List[str]) -> bool:
|
|
200
|
+
"""
|
|
201
|
+
Deliver ForgeQE's messages to the agent on its next step, next to the library's own loop nudge.
|
|
202
|
+
|
|
203
|
+
The library clears per-step context before each step, so the queue is drained from inside the step,
|
|
204
|
+
after that reset. False when this library version has no such hook; the caller then stops instead of nudging.
|
|
205
|
+
"""
|
|
206
|
+
hook = getattr(agent, "_inject_loop_detection_nudge", None)
|
|
207
|
+
manager = getattr(agent, "_message_manager", None)
|
|
208
|
+
if not callable(hook) or manager is None or not hasattr(manager, "_add_context_message"):
|
|
209
|
+
return False
|
|
210
|
+
try:
|
|
211
|
+
from browser_use.llm.messages import UserMessage
|
|
212
|
+
except Exception:
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
def inject() -> None:
|
|
216
|
+
hook()
|
|
217
|
+
while queue:
|
|
218
|
+
manager._add_context_message(UserMessage(content=queue.pop(0)))
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
agent._inject_loop_detection_nudge = inject
|
|
222
|
+
except Exception:
|
|
223
|
+
return False
|
|
224
|
+
return True
|