@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,244 @@
1
+ """
2
+ The application's API calls during a run (XHR, fetch, event streams), for API and contract tests: method, URL, status,
3
+ timing, and the request and response bodies when they are JSON, XML or text and small. Secrets never reach the file:
4
+ credentials in headers are dropped (only the kind of auth is kept), and fields named like a password, token or key are
5
+ redacted in bodies and query strings.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import re
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ MAX_CALLS = 300
21
+ MAX_BODY = 64_000
22
+ API_TYPES = {"XHR", "Fetch", "EventSource"}
23
+ TEXT_MIME = re.compile(r"json|xml|text/|graphql|x-www-form-urlencoded|grpc-web-text", re.I)
24
+ SECRET_NAME = re.compile(
25
+ r"pass(word|wd|phrase)?$|secret|token|api[-_]?key|auth|cookie|session|^sid$|otp|^pin$|cvv|cvc|card.?number|^pan$|ssn|private[-_]?key|signature",
26
+ re.I,
27
+ )
28
+ KEEP_HEADERS = {"content-type", "accept", "soapaction", "x-requested-with", "grpc-status", "grpc-message", "x-grpc-web", "x-apollo-operation-name"}
29
+ TRACKERS = re.compile(
30
+ r"google-analytics|googletagmanager|doubleclick|googlesyndication|facebook\.(com|net)|segment\.(io|com)|hotjar|sentry\.io|"
31
+ r"newrelic|nr-data|datadoghq|clarity\.ms|mixpanel|amplitude|intercom|fullstory|launchdarkly|optimizely|bugsnag|appdynamics|"
32
+ r"dynatrace|applicationinsights|visualstudio\.com/v2/track|browser-intake",
33
+ re.I,
34
+ )
35
+ REDACTED = "<redacted>"
36
+
37
+
38
+ def redact_json(value: Any) -> Any:
39
+ if isinstance(value, dict):
40
+ return {k: (REDACTED if SECRET_NAME.search(str(k)) and value[k] not in (None, "") else redact_json(v)) for k, v in value.items()}
41
+ if isinstance(value, list):
42
+ return [redact_json(v) for v in value]
43
+ return value
44
+
45
+
46
+ def redact_xml(text: str) -> str:
47
+ def element(m: "re.Match[str]") -> str:
48
+ return f"{m.group(1)}{REDACTED}{m.group(3)}" if SECRET_NAME.search(m.group(2)) else m.group(0)
49
+
50
+ return re.sub(r"(<(?:\w+:)?([\w.-]+)(?:\s[^>]*)?>)[^<]+(</(?:\w+:)?\2>)", element, text)
51
+
52
+
53
+ def redact_query(url: str) -> str:
54
+ try:
55
+ u = urlsplit(url)
56
+ except ValueError:
57
+ return url
58
+ if not u.query:
59
+ return url
60
+ pairs = [(k, REDACTED if SECRET_NAME.search(k) and v else v) for k, v in parse_qsl(u.query, keep_blank_values=True)]
61
+ return urlunsplit((u.scheme, u.netloc, u.path, urlencode(pairs, safe="<>"), ""))
62
+
63
+
64
+ def redact_body(text: str, mime: str) -> str:
65
+ """A body with its secret fields redacted: JSON by key, XML by element, a form by field, other text as is."""
66
+ t = text.strip()
67
+ if "json" in mime or (t[:1] in "{[" and t[-1:] in "}]"):
68
+ try:
69
+ return json.dumps(redact_json(json.loads(t)), ensure_ascii=False)
70
+ except ValueError:
71
+ pass
72
+ if "xml" in mime or t.startswith("<"):
73
+ return redact_xml(text)
74
+ if "x-www-form-urlencoded" in mime:
75
+ return urlencode([(k, REDACTED if SECRET_NAME.search(k) and v else v) for k, v in parse_qsl(text, keep_blank_values=True)], safe="<>")
76
+ return text
77
+
78
+
79
+ def auth_kind(headers: Dict[str, Any]) -> Optional[Dict[str, str]]:
80
+ """How the request authenticated, without the credential: bearer, basic, cookie, or the API key header's name."""
81
+ low = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
82
+ if "authorization" in low:
83
+ scheme = low["authorization"].split(" ", 1)[0].lower()
84
+ return {"type": scheme if scheme in ("bearer", "basic", "digest", "negotiate", "ntlm") else "authorization"}
85
+ for k in low:
86
+ if k != "cookie" and SECRET_NAME.search(k) and k not in KEEP_HEADERS:
87
+ return {"type": "header", "name": k}
88
+ if "cookie" in low:
89
+ return {"type": "cookie"}
90
+ return None
91
+
92
+
93
+ def _site(host: str) -> str:
94
+ parts = host.split(":")[0].split(".")
95
+ return ".".join(parts[-2:]) if len(parts) >= 2 else host
96
+
97
+
98
+ class NetworkCapture:
99
+ def __init__(self) -> None:
100
+ self.calls: List[Dict[str, Any]] = []
101
+ self._pending: Dict[str, Dict[str, Any]] = {}
102
+ self._tasks: List[asyncio.Task[Any]] = []
103
+ self.page_url = ""
104
+ self.dropped = 0
105
+
106
+ def wanted(self, params: Dict[str, Any]) -> bool:
107
+ url = str((params.get("request") or {}).get("url") or "")
108
+ return str(params.get("type") or "") in API_TYPES and url.startswith("http") and not TRACKERS.search(url)
109
+
110
+ def on_request(self, params: Any, session_id: Optional[str] = None) -> None:
111
+ try:
112
+ if not self.wanted(params):
113
+ if str(params.get("type") or "") == "Document":
114
+ self.page_url = str((params.get("request") or {}).get("url") or self.page_url)
115
+ return
116
+ if len(self.calls) + len(self._pending) >= MAX_CALLS:
117
+ self.dropped += 1
118
+ return
119
+ req = params.get("request") or {}
120
+ headers = req.get("headers") or {}
121
+ mime = str(next((v for k, v in headers.items() if str(k).lower() == "content-type"), ""))
122
+ call: Dict[str, Any] = {
123
+ "t": float(params.get("wallTime") or time.time()),
124
+ "method": str(req.get("method") or "GET"),
125
+ "url": redact_query(str(req.get("url") or "")),
126
+ "type": str(params.get("type")),
127
+ "request": {"headers": {k: v for k, v in headers.items() if str(k).lower() in KEEP_HEADERS}},
128
+ "_ts": float(params.get("timestamp") or 0),
129
+ "_session": session_id,
130
+ "_has_post": bool(req.get("hasPostData")),
131
+ }
132
+ auth = auth_kind(headers)
133
+ if auth:
134
+ call["auth"] = auth
135
+ if self.page_url:
136
+ call["page"] = redact_query(self.page_url)
137
+ page_host, host = urlsplit(self.page_url).hostname or "", urlsplit(call["url"]).hostname or ""
138
+ if page_host and host and _site(page_host) != _site(host):
139
+ call["third_party"] = True
140
+ post = req.get("postData")
141
+ if isinstance(post, str) and post:
142
+ call["request"]["body"] = redact_body(post[:MAX_BODY], mime)
143
+ if len(post) > MAX_BODY:
144
+ call["request"]["truncated"] = True
145
+ self._pending[str(params.get("requestId"))] = call
146
+ except Exception:
147
+ logger.debug("request not recorded", exc_info=True)
148
+
149
+ def on_response(self, params: Any, session_id: Optional[str] = None) -> None:
150
+ try:
151
+ call = self._pending.get(str(params.get("requestId")))
152
+ if call is None:
153
+ return
154
+ res = params.get("response") or {}
155
+ headers = {k: v for k, v in (res.get("headers") or {}).items() if str(k).lower() in KEEP_HEADERS}
156
+ call["response"] = {"status": int(res.get("status") or 0), "mime": str(res.get("mimeType") or ""), "headers": headers}
157
+ except Exception:
158
+ logger.debug("response not recorded", exc_info=True)
159
+
160
+ def on_finished(self, params: Any, session_id: Optional[str] = None, client: Any = None) -> None:
161
+ call = self._pending.pop(str(params.get("requestId")), None)
162
+ if call is None:
163
+ return
164
+ if call["_ts"]:
165
+ call["ms"] = max(0, int((float(params.get("timestamp") or call["_ts"]) - call["_ts"]) * 1000))
166
+ call.setdefault("response", {"status": 0, "mime": "", "headers": {}})
167
+ call["response"]["size"] = int(params.get("encodedDataLength") or 0)
168
+ self.calls.append(call)
169
+ if client is not None:
170
+ # Bodies are fetched off the event loop: waiting for a reply inside an event handler would block the handler's own reader.
171
+ self._tasks.append(asyncio.ensure_future(self._bodies(client, str(params.get("requestId")), call)))
172
+
173
+ def on_failed(self, params: Any, session_id: Optional[str] = None) -> None:
174
+ call = self._pending.pop(str(params.get("requestId")), None)
175
+ if call is None or params.get("canceled"):
176
+ return
177
+ call["failed"] = str(params.get("errorText") or "failed")
178
+ call.setdefault("response", {"status": 0, "mime": "", "headers": {}})
179
+ self.calls.append(call)
180
+
181
+ async def _bodies(self, client: Any, request_id: str, call: Dict[str, Any]) -> None:
182
+ sid = call.get("_session")
183
+ try:
184
+ if call["_has_post"] and "body" not in call["request"]:
185
+ r = await client.send.Network.getRequestPostData(params={"requestId": request_id}, session_id=sid)
186
+ post = str((r or {}).get("postData") or "")
187
+ mime = str(call["request"]["headers"].get("Content-Type") or call["request"]["headers"].get("content-type") or "")
188
+ if post:
189
+ call["request"]["body"] = redact_body(post[:MAX_BODY], mime)
190
+ except Exception:
191
+ logger.debug("request body not read", exc_info=True)
192
+ res = call["response"]
193
+ if not TEXT_MIME.search(res.get("mime") or "") or res.get("size", 0) > MAX_BODY * 4:
194
+ return
195
+ try:
196
+ r = await client.send.Network.getResponseBody(params={"requestId": request_id}, session_id=sid)
197
+ if (r or {}).get("base64Encoded"):
198
+ return
199
+ body = str((r or {}).get("body") or "")
200
+ res["body"] = redact_body(body[:MAX_BODY], res.get("mime") or "")
201
+ if len(body) > MAX_BODY:
202
+ res["truncated"] = True
203
+ except Exception:
204
+ logger.debug("response body not read", exc_info=True)
205
+
206
+ async def settle(self, timeout: float = 5.0) -> None:
207
+ pending = [t for t in self._tasks if not t.done()]
208
+ if pending:
209
+ await asyncio.wait(pending, timeout=timeout)
210
+
211
+ def write(self, out_dir: Path) -> Optional[str]:
212
+ if not self.calls:
213
+ return None
214
+ calls = [{k: v for k, v in c.items() if not k.startswith("_")} for c in sorted(self.calls, key=lambda c: c["t"])]
215
+ file = out_dir / "network.json"
216
+ file.write_text(json.dumps({"version": 1, "calls": calls, **({"dropped": self.dropped} if self.dropped else {})}, indent=1, ensure_ascii=False), encoding="utf-8")
217
+ return str(file.resolve())
218
+
219
+
220
+ HOOKED = "_forgeqe_hook"
221
+
222
+
223
+ def hook(client: Any, method: str, fn: Any) -> None:
224
+ """
225
+ Add a handler for a CDP event without replacing the one already there: the CDP client keeps one handler per event,
226
+ and the library's own watchdogs (downloads, HAR) listen to the same network events.
227
+ """
228
+ registry = getattr(client, "_event_registry", None)
229
+ handlers = getattr(registry, "_handlers", None)
230
+ if handlers is None:
231
+ return
232
+ current = handlers.get(method)
233
+ if current is not None and getattr(current, HOOKED, None) is fn:
234
+ return
235
+
236
+ def both(params: Any, session_id: Optional[str] = None) -> Any:
237
+ try:
238
+ fn(params, session_id)
239
+ except Exception:
240
+ logger.debug("network handler failed", exc_info=True)
241
+ return current(params, session_id) if current is not None else None
242
+
243
+ setattr(both, HOOKED, fn)
244
+ registry.register(method, both)
@@ -0,0 +1,20 @@
1
+ """
2
+ The browser agent's instructions, configurable: ForgeQE sends each prompts/browser-*.md (the project's edited copy, or
3
+ its own) with the run request, and the engine uses those. The constants in the engine's modules are the same text, for
4
+ the engine on its own (tests, `python -m forgeqe_engine`); tests/engine/test_prompts.py keeps the two in step.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Dict
9
+
10
+ _OVERRIDES: Dict[str, str] = {}
11
+
12
+
13
+ def use_prompts(prompts: Dict[str, str]) -> None:
14
+ """Set the prompts for this run (name -> text); a name not given keeps the engine's own."""
15
+ _OVERRIDES.clear()
16
+ _OVERRIDES.update({str(k): str(v) for k, v in (prompts or {}).items() if isinstance(v, str)})
17
+
18
+
19
+ def text(name: str, default: str) -> str:
20
+ return _OVERRIDES.get(name, default)
@@ -0,0 +1,240 @@
1
+ """
2
+ Replay a recorded flow inside the run's own library session, without calling the LLM.
3
+
4
+ Each element step re-finds its element on the live page through the memory's ranked locators and
5
+ runs the same library action with the element's new index. Replay stops at the first step it
6
+ cannot do safely; the agent then continues from the page replay reached.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import os
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
16
+
17
+ from . import brand
18
+ from .logins import totp_code
19
+ from .memory import (
20
+ ELEMENT_ACTIONS,
21
+ READ_ONLY_ACTIONS,
22
+ REPLAYABLE_ACTIONS,
23
+ MemoryStore,
24
+ flow_step,
25
+ resolve_step,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ FIND_ATTEMPTS = 6
31
+ FIND_DELAY_SECONDS = 0.8
32
+
33
+ Emit = Callable[[Dict[str, Any]], None]
34
+
35
+
36
+ @dataclass
37
+ class ReplayOutcome:
38
+ total: int
39
+ done: List[Dict[str, Any]] = field(default_factory=list)
40
+ emitted: int = 0
41
+ stopped_at: Optional[int] = None
42
+ reason: str = ""
43
+ url: str = ""
44
+ # Start and end time of each step in `done`, for the report's timeline.
45
+ times: List[Tuple[float, float]] = field(default_factory=list)
46
+ # Steps whose recorded locator no longer matched and another one found the element.
47
+ heals: List[Dict[str, Any]] = field(default_factory=list)
48
+
49
+ @property
50
+ def complete(self) -> bool:
51
+ return self.stopped_at is None
52
+
53
+
54
+ async def _snapshot(browser: Any, store: MemoryStore) -> Tuple[Any, Dict[int, Dict[str, Any]]]:
55
+ state = await browser.get_browser_state_summary(include_screenshot=False)
56
+ snap = store.observe(state.url, state.title, state.dom_state.selector_map or {})
57
+ return state, snap
58
+
59
+
60
+ async def _current_url(browser: Any) -> str:
61
+ try:
62
+ return str(await browser.get_current_page_url() or "")
63
+ except Exception:
64
+ return ""
65
+
66
+
67
+ def _locator_note(target: Optional[Dict[str, Any]], used: Optional[Tuple[str, str]]) -> str:
68
+ if not target or not used:
69
+ return ""
70
+ for loc in target.get("locators") or []:
71
+ if (loc.get("type"), loc.get("value")) == used:
72
+ return f" [{loc['type']} {loc.get('confidence', 0)}%]"
73
+ return ""
74
+
75
+
76
+ def _locator_text(loc: Optional[Tuple[str, str]]) -> str:
77
+ return f"{loc[0]} {loc[1]}" if loc else "the element's fingerprint in page memory"
78
+
79
+
80
+ async def replay_flow(
81
+ flow: Dict[str, Any],
82
+ *,
83
+ browser: Any,
84
+ store: MemoryStore,
85
+ emit: Emit,
86
+ cancel_event: asyncio.Event,
87
+ on_step: Optional[Callable[[], Awaitable[None]]] = None,
88
+ overlay: Any = None,
89
+ ) -> ReplayOutcome:
90
+ from browser_use import Tools
91
+
92
+ tools = Tools()
93
+ action_model = tools.registry.create_action_model()
94
+ steps = [s for s in flow.get("steps") or [] if s.get("action") not in READ_ONLY_ACTIONS]
95
+ out = ReplayOutcome(total=len(steps))
96
+
97
+ def stop(n: int, reason: str) -> ReplayOutcome:
98
+ out.stopped_at = n
99
+ out.reason = reason
100
+ return out
101
+
102
+ for n, step in enumerate(steps, 1):
103
+ if cancel_event.is_set():
104
+ stop(n, "stopped by user")
105
+ break
106
+ action = str(step.get("action") or "")
107
+ params = dict(step.get("params") or {})
108
+ line = str(step.get("line") or action)
109
+ if action not in REPLAYABLE_ACTIONS:
110
+ stop(n, f"`{line}` needs the agent")
111
+ break
112
+ if step.get("secret"):
113
+ stop(n, f"`{line}` types into a password field that was not saved")
114
+ break
115
+ env = params.pop("text_env", None)
116
+ totp_env = params.pop("totp_env", None)
117
+ if env or totp_env:
118
+ if not os.environ.get(env or totp_env):
119
+ stop(n, f"`{line}` needs {env or totp_env}, which is not set")
120
+ break
121
+ params["text"] = totp_code(os.environ[totp_env]) if totp_env else os.environ[env]
122
+
123
+ started = time.time()
124
+ target: Optional[Dict[str, Any]] = None
125
+ used: Optional[Tuple[str, str]] = None
126
+ stale: List[Tuple[str, str]] = []
127
+ snap: Dict[int, Dict[str, Any]] = {}
128
+ state = None
129
+ if action in ELEMENT_ACTIONS and (step.get("locators") or step.get("element")):
130
+ for _ in range(FIND_ATTEMPTS):
131
+ state, snap = await _snapshot(browser, store)
132
+ idx, used, stale = resolve_step(step, snap, store)
133
+ if idx is not None:
134
+ target = snap[idx]
135
+ params["index"] = idx
136
+ break
137
+ if cancel_event.is_set():
138
+ break
139
+ await asyncio.sleep(FIND_DELAY_SECONDS)
140
+ if target is None:
141
+ stop(n, f'could not find "{step.get("label") or line}" on the page any more')
142
+ break
143
+
144
+ url = getattr(state, "url", None) or await _current_url(browser)
145
+ out.emitted = n
146
+ emit(
147
+ {
148
+ "type": "step",
149
+ "source": "memory",
150
+ "step_number": n,
151
+ "url": url,
152
+ "title": getattr(state, "title", "") or "",
153
+ "next_goal": f"Replaying step {n} of {out.total} from memory",
154
+ "action_lines": [line + _locator_note(target, used)],
155
+ "status_line": f"Step {n} (memory): {line}",
156
+ }
157
+ )
158
+ if overlay is not None:
159
+ doing = brand.plain_action(action, dict(step.get("params") or {}), str(step.get("label") or ""))
160
+ await overlay.acting(f"Step {n} of {out.total} from memory · {doing}", mode="replaying")
161
+ try:
162
+ result = await tools.act(action=action_model(**{action: params}), browser_session=browser)
163
+ err = str(getattr(result, "error", None) or "").strip()
164
+ detail = err or str(getattr(result, "extracted_content", None) or "").strip()
165
+ except Exception as e:
166
+ logger.debug("replay step %s failed", n, exc_info=True)
167
+ err = detail = f"{type(e).__name__}: {e}"
168
+ if overlay is not None:
169
+ await overlay.thinking()
170
+ ok = not err
171
+ emit({"type": "result", "step": n, "items": [{"ok": ok, "done": False, "text": (detail.splitlines() or [""])[0][:240]}]})
172
+ if target:
173
+ store.record_outcome(target["page"], target["key"], ok, used, stale if ok else ())
174
+ if ok and step.get("element") and (step.get("page"), step["element"]) != (target["page"], target["key"]):
175
+ store.record_stale(step.get("page") or target["page"], step["element"], stale)
176
+ if not ok:
177
+ stop(n, f"`{line}` failed: {err[:200]}")
178
+ break
179
+ recorded = {**params, "text": None, "totp_env": totp_env} if totp_env else params
180
+ out.done.append(flow_step(action, recorded, target, line) if target else step)
181
+ out.times.append((started, time.time()))
182
+ if target:
183
+ gone = [
184
+ (l["type"], l["value"]) for l in step.get("locators") or []
185
+ if not any((l["type"], l["value"]) in e["current"] for e in snap.values())
186
+ ]
187
+ if gone:
188
+ out.heals.append({"step": n, "line": line, "label": step.get("label") or "", "stale": [_locator_text(k) for k in gone], "used": _locator_text(used)})
189
+ if on_step is not None:
190
+ await on_step()
191
+
192
+ out.url = await _current_url(browser)
193
+ return out
194
+
195
+
196
+ def handoff_task(goal: str, flow: Dict[str, Any], outcome: ReplayOutcome, *, kind: str = "exact") -> str:
197
+ """
198
+ The agent's task after a replay: the goal, what already happened, and where to pick up.
199
+
200
+ `kind` is "exact" (an earlier successful run of this task), "resume" (the steps that worked in a run of this
201
+ task that later went wrong), or "interrupted" (the steps a run of this task got through before it was cut off: closed, crashed, or the machine restarted).
202
+ """
203
+ when = str(flow.get("last_success") or flow.get("at") or "")[:10]
204
+ if kind == "resume":
205
+ source = "an earlier run of this task that went wrong later"
206
+ elif kind == "interrupted":
207
+ source = "an earlier run of this task that was cut off before it finished"
208
+ else:
209
+ source = "an earlier successful run of this same task"
210
+ lines = [
211
+ goal,
212
+ "",
213
+ "## Already done (replayed from ForgeQE memory, do not repeat)",
214
+ f"These steps come from {source}{f' on {when}' if when else ''} and were just replayed in this browser:",
215
+ ]
216
+ lines += [f"{i}. {s.get('line') or s.get('action')}" for i, s in enumerate(outcome.done, 1)]
217
+ if not outcome.complete:
218
+ lines.append(f"Replay stopped early: {outcome.reason}. The browser is on {outcome.url or 'the current page'}; continue the task from here.")
219
+ elif kind == "exact":
220
+ lines.append(
221
+ f"All recorded steps ran and the browser is on {outcome.url or 'the final page'}. Check this page: "
222
+ "if the task is complete, call done right away and report the result. Otherwise continue from here."
223
+ )
224
+ elif kind == "interrupted":
225
+ lines.append(
226
+ f"That is as far as the earlier run got before it was cut off. The browser is on {outcome.url or 'the current page'}. "
227
+ "Continue the task from here without repeating those steps."
228
+ )
229
+ else:
230
+ lines.append(f"That is as far as the earlier run got on track. The browser is on {outcome.url or 'the current page'}.")
231
+ if kind == "resume" and flow.get("reason"):
232
+ lines.append(
233
+ f"The earlier run then went wrong: {flow['reason']} "
234
+ "Look at the page before acting, work out why that happened, and take a different approach to the rest of the task."
235
+ )
236
+ lines.append(
237
+ "Values typed or picked during replay came from the earlier run. If the task depends on today's date "
238
+ '(for example "tomorrow"), check them and fix any that are stale.'
239
+ )
240
+ return "\n".join(lines)
@@ -0,0 +1,173 @@
1
+ """
2
+ What a run leaves for the detailed report: a step timeline with screenshots (steps.json, steps/NN.jpg) and, for
3
+ test cases, the agent's own verdict on each numbered test step.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import io
9
+ import json
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ from .diagnostics import assign_problems
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ SHOT_WIDTH = 1280
21
+ SHOT_QUALITY = 70
22
+
23
+
24
+ class CaseStepResult(BaseModel):
25
+ step: int = Field(description="The test step's number, as numbered in the task")
26
+ status: Literal["passed", "failed", "not_run"] = Field(
27
+ description="passed: done and its expected result holds; failed: could not be done or its expected result does not hold; not_run: not reached"
28
+ )
29
+ actual: str = Field(description="What the page actually showed for this step, or why it could not be done")
30
+ cause: Optional[Literal["app", "test_case", "environment", "automation"]] = Field(
31
+ default=None,
32
+ description=(
33
+ "Only for a failed step, why it failed. app: the application does the wrong thing for what the step asks "
34
+ "(a wrong result, an error message, a missing confirmation): a product defect. test_case: the test case does "
35
+ "not fit the application: a page, field, or control it names does not exist or is named or placed "
36
+ "differently, or its expected result describes something else; the test case needs updating, not the app. "
37
+ "environment: the application or something it needs was unavailable or refused (an error page, a 5xx, a "
38
+ "network failure, sign-in or test data not available, a bot check). automation: the control is there but you "
39
+ "could not operate it (it did not respond to clicks or typing), or you ran out of steps."
40
+ ),
41
+ )
42
+
43
+
44
+ class CaseReport(BaseModel):
45
+ summary: str = Field(description="The overall result in a few sentences; name the first failing step and what the page showed instead")
46
+ steps: List[CaseStepResult] = Field(description="One entry per numbered test step, in order")
47
+
48
+
49
+ CASE_REPORT_INSTRUCTION = (
50
+ "## Test step report\n"
51
+ "This task is a test case with {n} numbered steps. When you finish (success or failure), report every step from 1 to {n}: "
52
+ "passed when it was done and its expected result holds, failed when it could not be done or its expected result does not hold, "
53
+ "not_run when you never reached it. For each step say what the page actually showed, and for a failed step its cause: app (a product "
54
+ "defect), test_case (the test case does not fit the application), environment (the app or something it needs was unavailable), or "
55
+ "automation (a control that is there could not be operated). Finish with success only if every step passed."
56
+ )
57
+
58
+
59
+ def case_report_from_history(history: Any) -> Optional[Dict[str, Any]]:
60
+ try:
61
+ report = history.structured_output
62
+ except Exception as ex:
63
+ logger.warning("the agent's step report could not be read: %s", ex)
64
+ return None
65
+ if report is None:
66
+ return None
67
+ data = report.model_dump(mode="json", exclude_none=True)
68
+ data["steps"] = sorted(data.get("steps") or [], key=lambda s: s.get("step") or 0)
69
+ return data
70
+
71
+
72
+ def _save_shot(b64: str, file: Path) -> bool:
73
+ try:
74
+ raw = base64.b64decode(b64.split(",", 1)[-1] + "=" * ((-len(b64)) % 4))
75
+ from PIL import Image
76
+
77
+ img = Image.open(io.BytesIO(raw)).convert("RGB")
78
+ if img.width > SHOT_WIDTH:
79
+ img = img.resize((SHOT_WIDTH, int(img.height * SHOT_WIDTH / img.width)))
80
+ file.parent.mkdir(parents=True, exist_ok=True)
81
+ img.save(file, "JPEG", quality=SHOT_QUALITY, optimize=True)
82
+ return True
83
+ except Exception as ex:
84
+ logger.debug("step screenshot not saved: %s", ex)
85
+ return False
86
+
87
+
88
+ def build_timeline(
89
+ out_dir: Path,
90
+ *,
91
+ history: Any,
92
+ collected_steps: List[Dict[str, Any]],
93
+ replayed: List[Dict[str, Any]],
94
+ screenshots: str,
95
+ ok: bool,
96
+ replay_times: Sequence[Tuple[float, float]] = (),
97
+ llm_calls: Sequence[Tuple[float, float]] = (),
98
+ events: Sequence[Dict[str, Any]] = (),
99
+ ) -> Optional[str]:
100
+ """
101
+ Write steps.json: steps replayed from memory, then each agent step with its actions, where each action landed on
102
+ the screenshot, timing (and how much of it the model took), the screenshot, and the browser problems during it.
103
+ """
104
+ entries: List[Dict[str, Any]] = []
105
+ for i, s in enumerate(replayed):
106
+ url = (s.get("params") or {}).get("url") or s.get("page") or ""
107
+ entry = {"n": len(entries) + 1, "source": "memory", "url": url, "title": "", "goal": "", "actions": [{"text": s.get("line") or s.get("action") or "", "ok": True}]}
108
+ if i < len(replay_times):
109
+ t0, t1 = replay_times[i]
110
+ entry.update(t0=round(t0, 3), ms=int(max(0.0, t1 - t0) * 1000), model_ms=0)
111
+ entries.append(entry)
112
+ payloads = {int(p.get("step_number") or 0): p for p in collected_steps}
113
+ offset = len(replayed)
114
+ save_shots = screenshots == "all" or (screenshots == "failures" and not ok)
115
+ for item in list(getattr(history, "history", None) or []):
116
+ meta = getattr(item, "metadata", None)
117
+ number = int(getattr(meta, "step_number", 0) or 0)
118
+ payload = payloads.get(number + offset) or payloads.get(number) or {}
119
+ state = getattr(item, "state", None)
120
+ output = getattr(item, "model_output", None)
121
+ results = list(getattr(item, "result", None) or [])
122
+ lines = list(payload.get("action_lines") or [])
123
+ page_url = str(getattr(state, "url", "") or "")
124
+ if number == 0 and not lines:
125
+ # The library records its initial actions (a pinned start page) as step 0, not a model decision
126
+ if not page_url.startswith("http") and not any(getattr(r, "error", None) for r in results):
127
+ continue
128
+ lines = [f"open {page_url}"] * len(results)
129
+ output = None
130
+ actions = []
131
+ for i, r in enumerate(results):
132
+ err = str(getattr(r, "error", None) or "").strip()
133
+ if getattr(r, "is_done", False):
134
+ actions.append({"text": "done: " + ("success" if getattr(r, "success", False) else "failure"), "ok": not err, "done": True})
135
+ continue
136
+ action = {"text": lines[i] if i < len(lines) else "action", "ok": not err, **({"error": err[:400]} if err else {})}
137
+ boxes = payload.get("boxes") or []
138
+ if i < len(boxes) and boxes[i]:
139
+ action["box"] = boxes[i]
140
+ actions.append(action)
141
+ n = len(entries) + 1
142
+ entry: Dict[str, Any] = {
143
+ "n": n,
144
+ "source": "agent",
145
+ "url": page_url,
146
+ "title": str(getattr(state, "title", "") or ""),
147
+ "goal": str(getattr(output, "next_goal", "") or ""),
148
+ "evaluation": str(getattr(output, "evaluation_previous_goal", "") or ""),
149
+ "memory": str(getattr(output, "memory", "") or ""),
150
+ "actions": actions,
151
+ }
152
+ try:
153
+ t0, t1 = float(meta.step_start_time), float(meta.step_end_time)
154
+ entry["t0"] = round(t0, 3)
155
+ entry["ms"] = int(max(0.0, t1 - t0) * 1000)
156
+ entry["model_ms"] = int(sum(max(0.0, min(b, t1) - max(a, t0)) for a, b in llm_calls) * 1000)
157
+ except Exception:
158
+ pass
159
+ if save_shots and state is not None:
160
+ try:
161
+ b64 = state.get_screenshot()
162
+ except Exception:
163
+ b64 = None
164
+ rel = f"steps/{n:02d}.jpg"
165
+ if b64 and _save_shot(b64, out_dir / rel):
166
+ entry["screenshot"] = rel
167
+ entries.append(entry)
168
+ if not entries:
169
+ return None
170
+ assign_problems(entries, list(events))
171
+ file = out_dir / "steps.json"
172
+ file.write_text(json.dumps(entries, indent=2, ensure_ascii=False), encoding="utf-8")
173
+ return str(file.resolve())