@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,689 @@
1
+ """
2
+ Persist engine runs as compact YAML (format ``forgeqe-v2-compact``).
3
+
4
+ Walks AgentHistory (or the live per-step trace when history is missing) into replayable
5
+ steps with semantic locators, and records execution metadata that explains partial runs.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ from collections import Counter
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ import yaml
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ FORMAT_V2_COMPACT = "forgeqe-v2-compact"
24
+
25
+
26
+ def _sanitize_jsonish(obj: Any) -> Any:
27
+ """Recursively coerce values to YAML/JSON-safe primitives."""
28
+ if obj is None or isinstance(obj, (bool, int, float, str)):
29
+ return obj
30
+ if isinstance(obj, dict):
31
+ return {str(k): _sanitize_jsonish(v) for k, v in obj.items()}
32
+ if isinstance(obj, (list, tuple)):
33
+ return [_sanitize_jsonish(x) for x in obj]
34
+ if isinstance(obj, (datetime,)):
35
+ return obj.isoformat()
36
+ try:
37
+ return json.loads(json.dumps(obj, default=str))
38
+ except (TypeError, ValueError):
39
+ return str(obj)
40
+
41
+
42
+ def _truthy_include_full_history() -> bool:
43
+ raw = os.getenv("BROWSER_USE_YAML_INCLUDE_FULL_HISTORY")
44
+ if raw is None:
45
+ return False
46
+ return str(raw).strip().lower() in {"1", "true", "yes", "on"}
47
+
48
+
49
+ def _to_action_dict(act: Any) -> Optional[Dict[str, Any]]:
50
+ if act is None:
51
+ return None
52
+ if isinstance(act, dict):
53
+ return act
54
+ try:
55
+ if hasattr(act, "model_dump"):
56
+ return act.model_dump(mode="json")
57
+ except Exception:
58
+ pass
59
+ return None
60
+
61
+
62
+ def _to_dict_model(obj: Any) -> Optional[Dict[str, Any]]:
63
+ if obj is None:
64
+ return None
65
+ if isinstance(obj, dict):
66
+ return obj
67
+ try:
68
+ if hasattr(obj, "model_dump"):
69
+ return obj.model_dump(mode="json")
70
+ # DOMInteractedElement is a dataclass whose to_dict() omits ax_name.
71
+ if hasattr(obj, "to_dict"):
72
+ d = dict(obj.to_dict())
73
+ if getattr(obj, "ax_name", None) is not None:
74
+ d.setdefault("ax_name", obj.ax_name)
75
+ return d
76
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
77
+ return dataclasses.asdict(obj)
78
+ except Exception:
79
+ pass
80
+ return None
81
+
82
+
83
+ def _locator_from_interacted(ie: Any) -> Dict[str, Any]:
84
+ """Pick stable fields from the library's interacted_element for replay / test specs."""
85
+ d = _to_dict_model(ie)
86
+ if not d:
87
+ return {}
88
+ attrs = d.get("attributes") if isinstance(d.get("attributes"), dict) else {}
89
+ out: Dict[str, Any] = {}
90
+ if attrs.get("id"):
91
+ out["id"] = str(attrs["id"])
92
+ for key in ("data-test-id", "data-testid"):
93
+ if attrs.get(key):
94
+ out["test_id"] = str(attrs[key])
95
+ break
96
+ if attrs.get("name"):
97
+ out["name_attr"] = str(attrs["name"])
98
+ if attrs.get("href"):
99
+ out["href"] = str(attrs["href"])
100
+ if attrs.get("role"):
101
+ out["role"] = str(attrs["role"])
102
+ if attrs.get("type"):
103
+ out["input_type"] = str(attrs["type"])
104
+ an = d.get("ax_name")
105
+ if an is not None and str(an).strip():
106
+ out["name"] = str(an).strip()
107
+ nn = d.get("node_name")
108
+ if nn:
109
+ out["tag"] = str(nn).strip()
110
+ return out
111
+
112
+
113
+ def _locator_from_extracted_text(text: str) -> Dict[str, Any]:
114
+ """
115
+ Parse compact locator hints from the library's extracted_content lines.
116
+ Examples:
117
+ Clicked button "Accept cookies" id=onetrust-accept-btn-handler
118
+ Clicked input type=button role=combobox id=:R1bmt6:-form-item name=Title
119
+ Clicked li role=option "Mr" id=listbox-xxxx-option-0
120
+ """
121
+ t = (text or "").strip()
122
+ if not t:
123
+ return {}
124
+ out: Dict[str, Any] = {}
125
+
126
+ m_testid = re.search(r"\b(?:data-test-id|data-testid)=([^\s]+)", t)
127
+ if m_testid:
128
+ out["test_id"] = m_testid.group(1).strip().strip('"').strip("'")
129
+ m_id = re.search(r"\bid=([^\s]+)", t)
130
+ if m_id:
131
+ out["id"] = m_id.group(1).strip().strip('"').strip("'")
132
+ m_role = re.search(r"\brole=([^\s]+)", t)
133
+ if m_role:
134
+ out["role"] = m_role.group(1).strip().strip('"').strip("'")
135
+ m_type = re.search(r"\btype=([^\s]+)", t)
136
+ if m_type:
137
+ out["input_type"] = m_type.group(1).strip().strip('"').strip("'")
138
+ m_name_attr = re.search(r"\bname=([^\s]+)", t)
139
+ if m_name_attr:
140
+ out["name_attr"] = m_name_attr.group(1).strip().strip('"').strip("'")
141
+
142
+ m_label = re.search(r'\"([^\"]+)\"', t)
143
+ if m_label:
144
+ lbl = m_label.group(1).strip()
145
+ if lbl:
146
+ out["name"] = lbl
147
+
148
+ lower = t.lower()
149
+ if "clicked button" in lower:
150
+ out.setdefault("tag", "BUTTON")
151
+ elif "clicked input" in lower or "typed '" in lower:
152
+ out.setdefault("tag", "INPUT")
153
+ elif "clicked li" in lower:
154
+ out.setdefault("tag", "LI")
155
+ elif "clicked a " in lower or "clicked link" in lower:
156
+ out.setdefault("tag", "A")
157
+
158
+ return out
159
+
160
+
161
+ def _merge_locator(primary: Dict[str, Any], fallback: Dict[str, Any]) -> Dict[str, Any]:
162
+ if not primary:
163
+ return dict(fallback or {})
164
+ if not fallback:
165
+ return dict(primary or {})
166
+ out = dict(primary)
167
+ for k, v in fallback.items():
168
+ if k not in out or out.get(k) in (None, ""):
169
+ out[k] = v
170
+ return out
171
+
172
+
173
+ def _normalize_locator_for_replay(locator: Dict[str, Any], extracted_text: str) -> Dict[str, Any]:
174
+ """Canonicalize flaky locators so capture and replay use the same stable intent."""
175
+ loc = dict(locator or {})
176
+ text = (extracted_text or "").strip().lower()
177
+ name = str(loc.get("name") or "").strip().lower()
178
+ eid = str(loc.get("id") or "").strip()
179
+ tag = str(loc.get("tag") or "").strip().upper()
180
+
181
+ # Cookie banners often expose a dialog/container id while the actionable control is a button.
182
+ cookie_accept = (
183
+ "allow all cookies" in name
184
+ or "accept all" in name
185
+ or ("cookie" in text and ("allow" in text or "accept" in text))
186
+ )
187
+ weak_cookie_container_id = eid in {"CybotCookiebotDialog", "CybotCookiebotDialogBodyUnderlay"}
188
+ if cookie_accept and weak_cookie_container_id:
189
+ loc.pop("id", None)
190
+ if not str(loc.get("tag") or "").strip():
191
+ loc["tag"] = "BUTTON"
192
+ elif tag in {"DIV", "SPAN"}:
193
+ loc["tag"] = "BUTTON"
194
+
195
+ return loc
196
+
197
+
198
+ def _semantic_locators_from_locator(locator: Dict[str, Any]) -> Dict[str, Any]:
199
+ """Explicit semantic locator variants for deterministic replay, Playwright-friendly."""
200
+ loc = locator or {}
201
+ sem: Dict[str, Any] = {}
202
+
203
+ test_id = str(loc.get("test_id") or "").strip()
204
+ elem_id = str(loc.get("id") or "").strip()
205
+ role = str(loc.get("role") or "").strip()
206
+ name = str(loc.get("name") or "").strip()
207
+ name_attr = str(loc.get("name_attr") or "").strip()
208
+ href = str(loc.get("href") or "").strip()
209
+ tag = str(loc.get("tag") or "").strip().upper()
210
+
211
+ if test_id:
212
+ sem["by_test_id"] = test_id
213
+ if elem_id:
214
+ sem["by_id"] = elem_id
215
+ if name_attr:
216
+ sem["by_name_attr"] = name_attr
217
+ if href:
218
+ sem["by_href"] = href
219
+ if role:
220
+ sem["by_role"] = {"role": role, "name": name} if name else {"role": role}
221
+ if name:
222
+ sem["by_text"] = name
223
+ sem["by_label"] = name
224
+ if tag:
225
+ sem["element_tag"] = tag
226
+
227
+ return sem
228
+
229
+
230
+ def _selector_candidates_from_semantic(semantic: Dict[str, Any]) -> List[Dict[str, Any]]:
231
+ """Ordered selector attempts used by replay. Earlier entries are preferred."""
232
+ s = semantic or {}
233
+ out: List[Dict[str, Any]] = []
234
+ if s.get("by_test_id"):
235
+ out.append({"type": "test_id", "value": s["by_test_id"]})
236
+ if s.get("by_id"):
237
+ out.append({"type": "css", "value": f"#{s['by_id']}"})
238
+ if s.get("by_name_attr"):
239
+ out.append({"type": "css", "value": f"[name='{s['by_name_attr']}']"})
240
+ if s.get("by_href"):
241
+ out.append({"type": "css", "value": f"a[href='{s['by_href']}']"})
242
+ by_role = s.get("by_role")
243
+ if isinstance(by_role, dict) and by_role.get("role"):
244
+ cand = {"type": "role", "role": str(by_role["role"])}
245
+ if by_role.get("name"):
246
+ cand["name"] = str(by_role["name"])
247
+ out.append(cand)
248
+ if s.get("by_label"):
249
+ out.append({"type": "label", "value": s["by_label"]})
250
+ if s.get("by_text"):
251
+ out.append({"type": "text", "value": s["by_text"]})
252
+ return out
253
+
254
+
255
+ def _is_actionable_locator(locator: Dict[str, Any]) -> bool:
256
+ if not isinstance(locator, dict) or not locator:
257
+ return False
258
+ keys = ("test_id", "id", "name_attr", "href", "name", "role")
259
+ return any(str(locator.get(k) or "").strip() for k in keys)
260
+
261
+
262
+ def build_compact_steps_from_history(history: Any) -> List[Dict[str, Any]]:
263
+ """
264
+ Walk the library's AgentHistory and emit minimal steps for deterministic replay and readable YAML.
265
+
266
+ Each step: op, optional url/title/page_url, locator (semantic), index fallback for audit.
267
+ """
268
+ out: List[Dict[str, Any]] = []
269
+ entries = getattr(history, "history", None)
270
+ if not isinstance(entries, list):
271
+ return out
272
+
273
+ for entry in entries:
274
+ mo = getattr(entry, "model_output", None)
275
+ if mo is None:
276
+ continue
277
+ actions = getattr(mo, "action", None) or []
278
+ if not isinstance(actions, list):
279
+ continue
280
+ results = getattr(entry, "result", None) or []
281
+ if not isinstance(results, list):
282
+ results = []
283
+ st = getattr(entry, "state", None)
284
+ url = ""
285
+ title = ""
286
+ if st is not None:
287
+ url = getattr(st, "url", None) or ""
288
+ title = getattr(st, "title", None) or ""
289
+ interacted = getattr(st, "interacted_element", None) if st is not None else None
290
+ if not isinstance(interacted, list):
291
+ interacted = [interacted] if interacted is not None else []
292
+ ie_list: List[Optional[Dict[str, Any]]] = []
293
+ for ie in interacted:
294
+ ie_list.append(_to_dict_model(ie))
295
+
296
+ # The library keeps interacted_element and result aligned with the actions, one slot per action.
297
+ for ie_idx, act in enumerate(actions):
298
+ ad = _to_action_dict(act)
299
+ if not ad:
300
+ continue
301
+ if "done" in ad:
302
+ continue
303
+ if "navigate" in ad and isinstance(ad["navigate"], dict):
304
+ nav = ad["navigate"]
305
+ out.append({"op": "navigate", "url": nav.get("url") or "", "page_url": url, "title": title})
306
+ continue
307
+ if "go_to_url" in ad and isinstance(ad["go_to_url"], dict):
308
+ gu = ad["go_to_url"]
309
+ out.append({"op": "navigate", "url": gu.get("url") or "", "page_url": url, "title": title})
310
+ continue
311
+ if "click" in ad and isinstance(ad["click"], dict):
312
+ ck = ad["click"]
313
+ idx = ck.get("index")
314
+ ie = ie_list[ie_idx] if ie_idx < len(ie_list) else None
315
+ loc = _locator_from_interacted(ie)
316
+ res_txt = ""
317
+ if ie_idx < len(results):
318
+ rr = _to_dict_model(results[ie_idx]) or {}
319
+ res_txt = str(rr.get("extracted_content") or "")
320
+ loc = _merge_locator(loc, _locator_from_extracted_text(res_txt))
321
+ loc = _normalize_locator_for_replay(loc, res_txt)
322
+ sem = _semantic_locators_from_locator(loc)
323
+ cands = _selector_candidates_from_semantic(sem)
324
+ if _is_actionable_locator(loc) or len(cands) > 0:
325
+ out.append(
326
+ {
327
+ "op": "click",
328
+ "page_url": url,
329
+ "title": title,
330
+ "index": idx,
331
+ "locator": loc,
332
+ "semantic_locators": sem,
333
+ "selector_candidates": cands,
334
+ }
335
+ )
336
+ continue
337
+ inp_dict: Optional[Dict[str, Any]] = None
338
+ if "input" in ad and isinstance(ad["input"], dict):
339
+ inp_dict = ad["input"]
340
+ elif "input_text" in ad and isinstance(ad["input_text"], dict):
341
+ it0 = ad["input_text"]
342
+ inp_dict = {
343
+ "text": it0.get("text") if it0.get("text") is not None else it0.get("value") or "",
344
+ "clear": bool(it0.get("clear", True)),
345
+ "index": it0.get("index"),
346
+ }
347
+ if inp_dict is not None:
348
+ inp = inp_dict
349
+ ie = ie_list[ie_idx] if ie_idx < len(ie_list) else None
350
+ loc = _locator_from_interacted(ie)
351
+ res_txt = ""
352
+ if ie_idx < len(results):
353
+ rr = _to_dict_model(results[ie_idx]) or {}
354
+ res_txt = str(rr.get("extracted_content") or "")
355
+ loc = _merge_locator(loc, _locator_from_extracted_text(res_txt))
356
+ loc = _normalize_locator_for_replay(loc, res_txt)
357
+ sem = _semantic_locators_from_locator(loc)
358
+ cands = _selector_candidates_from_semantic(sem)
359
+ if _is_actionable_locator(loc) or len(cands) > 0:
360
+ out.append(
361
+ {
362
+ "op": "input",
363
+ "page_url": url,
364
+ "title": title,
365
+ "text": inp.get("text", ""),
366
+ "clear": bool(inp.get("clear", False)),
367
+ "index": inp.get("index"),
368
+ "locator": loc,
369
+ "semantic_locators": sem,
370
+ "selector_candidates": cands,
371
+ }
372
+ )
373
+ continue
374
+ if "wait" in ad and isinstance(ad["wait"], dict):
375
+ w = ad["wait"]
376
+ try:
377
+ sec = float(w.get("seconds") or 0)
378
+ except (TypeError, ValueError):
379
+ sec = 0.0
380
+ out.append({"op": "wait", "seconds": sec, "page_url": url})
381
+ continue
382
+ keys = [k for k in ad.keys() if k not in ("done",)]
383
+ if keys:
384
+ out.append({"op": "unknown", "action_keys": keys, "page_url": url, "raw": ad})
385
+
386
+ return out
387
+
388
+
389
+ def _first_navigate_step_from_live_trace(collected_steps: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
390
+ """When full action parsing yields nothing, emit a single navigate so replay can at least open the app."""
391
+ if not isinstance(collected_steps, list):
392
+ return None
393
+ for st in collected_steps:
394
+ if not isinstance(st, dict):
395
+ continue
396
+ page_url = str(st.get("url") or "").strip()
397
+ title = str(st.get("title") or "").strip()
398
+ for act in st.get("actions") or []:
399
+ if not isinstance(act, dict):
400
+ continue
401
+ nav = act.get("navigate")
402
+ if isinstance(nav, dict):
403
+ u = str(nav.get("url") or "").strip()
404
+ if u:
405
+ return {"op": "navigate", "url": u, "page_url": page_url or u, "title": title}
406
+ gu = act.get("go_to_url")
407
+ if isinstance(gu, dict):
408
+ u = str(gu.get("url") or "").strip()
409
+ if u:
410
+ return {"op": "navigate", "url": u, "page_url": page_url or u, "title": title}
411
+ if page_url.startswith("http"):
412
+ return {"op": "navigate", "url": page_url, "page_url": page_url, "title": title}
413
+ return None
414
+
415
+
416
+ def build_compact_steps_from_live_trace(collected_steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
417
+ """Rebuild compact replay steps from per-step callback payloads when AgentHistory is unavailable."""
418
+ out: List[Dict[str, Any]] = []
419
+ if not isinstance(collected_steps, list):
420
+ return out
421
+ for st in collected_steps:
422
+ if not isinstance(st, dict):
423
+ continue
424
+ url = str(st.get("url") or "")
425
+ title = str(st.get("title") or "")
426
+ for act in st.get("actions") or []:
427
+ if not isinstance(act, dict):
428
+ continue
429
+ if "navigate" in act and isinstance(act["navigate"], dict):
430
+ nu = str(act["navigate"].get("url") or "").strip()
431
+ if nu:
432
+ out.append({"op": "navigate", "url": nu, "page_url": url, "title": title})
433
+ continue
434
+ if "go_to_url" in act and isinstance(act["go_to_url"], dict):
435
+ nu = str(act["go_to_url"].get("url") or "").strip()
436
+ if nu:
437
+ out.append({"op": "navigate", "url": nu, "page_url": url, "title": title})
438
+ continue
439
+ if "click" in act and isinstance(act["click"], dict):
440
+ idx = act["click"].get("index")
441
+ ie = act.get("interacted_element")
442
+ loc = _locator_from_interacted(ie) if ie is not None else {}
443
+ sem = _semantic_locators_from_locator(loc)
444
+ cands = _selector_candidates_from_semantic(sem)
445
+ if _is_actionable_locator(loc) or cands:
446
+ out.append(
447
+ {
448
+ "op": "click",
449
+ "page_url": url,
450
+ "title": title,
451
+ "index": idx,
452
+ "locator": loc,
453
+ "semantic_locators": sem,
454
+ "selector_candidates": cands,
455
+ }
456
+ )
457
+ continue
458
+ inp_live: Optional[Dict[str, Any]] = None
459
+ if "input" in act and isinstance(act["input"], dict):
460
+ inp_live = act["input"]
461
+ elif "input_text" in act and isinstance(act["input_text"], dict):
462
+ it1 = act["input_text"]
463
+ inp_live = {
464
+ "text": it1.get("text") if it1.get("text") is not None else it1.get("value") or "",
465
+ "clear": bool(it1.get("clear", True)),
466
+ "index": it1.get("index"),
467
+ }
468
+ if inp_live is not None:
469
+ ie = act.get("interacted_element")
470
+ loc = _locator_from_interacted(ie) if ie is not None else {}
471
+ sem = _semantic_locators_from_locator(loc)
472
+ cands = _selector_candidates_from_semantic(sem)
473
+ if _is_actionable_locator(loc) or cands:
474
+ out.append(
475
+ {
476
+ "op": "input",
477
+ "page_url": url,
478
+ "title": title,
479
+ "text": str(inp_live.get("text") or ""),
480
+ "clear": bool(inp_live.get("clear", False)),
481
+ "index": inp_live.get("index"),
482
+ "locator": loc,
483
+ "semantic_locators": sem,
484
+ "selector_candidates": cands,
485
+ }
486
+ )
487
+ continue
488
+ if "wait" in act and isinstance(act["wait"], dict):
489
+ try:
490
+ sec = float(act["wait"].get("seconds") or 0)
491
+ except (TypeError, ValueError):
492
+ sec = 0.0
493
+ out.append({"op": "wait", "seconds": sec, "page_url": url})
494
+ continue
495
+ keys = [k for k in act.keys() if k not in ("done", "interacted_element")]
496
+ if keys:
497
+ out.append({"op": "unknown", "action_keys": keys, "page_url": url, "raw": act})
498
+ return out
499
+
500
+
501
+ def build_execution_context_for_live_trace(
502
+ *,
503
+ success: bool,
504
+ summary: str,
505
+ compact_steps: List[Dict[str, Any]],
506
+ source: str = "live_trace",
507
+ ) -> Dict[str, Any]:
508
+ """Execution metadata when AgentHistory is not available."""
509
+ nav_n = sum(1 for s in compact_steps if s.get("op") == "navigate")
510
+ interact_n = sum(1 for s in compact_steps if s.get("op") in ("click", "input"))
511
+ wait_n = sum(1 for s in compact_steps if s.get("op") == "wait")
512
+ unknown_n = sum(1 for s in compact_steps if s.get("op") == "unknown")
513
+ notes = [
514
+ "This workflow was saved from the live step trace (per-step callback), "
515
+ f"because the run ended with `{source}` or history could not be serialized. "
516
+ "Replay may be partial; run the goal again after fixes for a full history-based file."
517
+ ]
518
+ return _sanitize_jsonish(
519
+ {
520
+ "run_successful": success,
521
+ "recorded_counts": {"navigate": nav_n, "click_input": interact_n, "wait": wait_n, "unknown": unknown_n},
522
+ "summary_excerpt": (summary or "")[:2500],
523
+ "replay_notes": notes,
524
+ }
525
+ )
526
+
527
+
528
+ def build_execution_context_for_yaml(
529
+ history: Any,
530
+ *,
531
+ success: bool,
532
+ summary: str,
533
+ compact_steps: List[Dict[str, Any]],
534
+ ) -> Dict[str, Any]:
535
+ """Metadata that explains why replay may be partial (e.g. LLM connection errors after the first navigate)."""
536
+ nav_n = sum(1 for s in compact_steps if s.get("op") == "navigate")
537
+ interact_n = sum(1 for s in compact_steps if s.get("op") in ("click", "input"))
538
+ wait_n = sum(1 for s in compact_steps if s.get("op") == "wait")
539
+ unknown_n = sum(1 for s in compact_steps if s.get("op") == "unknown")
540
+
541
+ ctx: Dict[str, Any] = {
542
+ "run_successful": success,
543
+ "recorded_counts": {"navigate": nav_n, "click_input": interact_n, "wait": wait_n, "unknown": unknown_n},
544
+ "summary_excerpt": (summary or "")[:2500],
545
+ }
546
+
547
+ try:
548
+ urls = [u for u in (history.urls() or []) if u]
549
+ ctx["urls_visited"] = urls[-20:]
550
+ ctx["last_url"] = urls[-1] if urls else ""
551
+ except Exception:
552
+ ctx["urls_visited"] = []
553
+ ctx["last_url"] = ""
554
+
555
+ try:
556
+ raw_errs = [str(e).strip() for e in history.errors() if e and str(e).strip()]
557
+ ctx["errors"] = [{"message": m, "count": c} for m, c in Counter(raw_errs).most_common(12)]
558
+ ctx["error_total"] = len(raw_errs)
559
+ except Exception:
560
+ ctx["errors"] = []
561
+ ctx["error_total"] = 0
562
+
563
+ joined = " ".join(str(e.get("message", "")) for e in ctx.get("errors", [])).lower()
564
+ replay_lines: List[str] = []
565
+ blank_input_steps = sum(
566
+ 1 for s in compact_steps if isinstance(s, dict) and s.get("op") == "input" and not str(s.get("text") or "").strip()
567
+ )
568
+ if blank_input_steps:
569
+ replay_lines.append(
570
+ f"{blank_input_steps} recorded input step(s) have empty text (common when passwords are redacted). "
571
+ "Set FORGEQE_ENGINE_DEFAULT_LOGIN_USERNAME / FORGEQE_ENGINE_DEFAULT_LOGIN_PASSWORD to fill them."
572
+ )
573
+ if not success:
574
+ if "connection" in joined or "connect" in joined:
575
+ replay_lines.append(
576
+ "ForgeQE lost contact with the LLM API after the steps listed above. "
577
+ "Check the profile's endpoint, key, VPN/firewall, and quotas, then run again."
578
+ )
579
+ if interact_n == 0:
580
+ replay_lines.append(
581
+ "No click or input was recorded; steps may only contain navigate. "
582
+ "Replay can open the start URL but cannot complete the goal until a successful run records UI actions."
583
+ )
584
+ elif interact_n > 0:
585
+ replay_lines.append(
586
+ f"Partial recording: {interact_n} click/input step(s) saved. "
587
+ "Replay runs those actions in order; it fails if the DOM or locators changed."
588
+ )
589
+
590
+ ctx["replay_notes"] = replay_lines
591
+ return _sanitize_jsonish(ctx)
592
+
593
+
594
+ def serialize_browser_use_history(history: Any) -> Any:
595
+ """Best-effort serialization of the library's AgentHistory (or similar)."""
596
+ if history is None:
597
+ return None
598
+ try:
599
+ if hasattr(history, "model_dump"):
600
+ return _sanitize_jsonish(history.model_dump(mode="json"))
601
+ except Exception:
602
+ logger.debug("model_dump(mode=json) failed, falling back", exc_info=True)
603
+ try:
604
+ if hasattr(history, "model_dump"):
605
+ return _sanitize_jsonish(history.model_dump())
606
+ except Exception:
607
+ logger.debug("model_dump failed", exc_info=True)
608
+ if isinstance(history, (dict, list)):
609
+ return _sanitize_jsonish(history)
610
+ return {"_note": "unserialized_history", "repr": repr(history)}
611
+
612
+
613
+ def build_compact_steps(history: Any, collected_steps: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
614
+ """History first, then the live trace, then a single navigate — the same fallback order as the save path."""
615
+ compact_steps: List[Dict[str, Any]] = []
616
+ if history is not None:
617
+ try:
618
+ compact_steps = build_compact_steps_from_history(history)
619
+ except Exception as e:
620
+ logger.warning("build_compact_steps_from_history failed, will try live trace: %s", e)
621
+ compact_steps = []
622
+ if not compact_steps and collected_steps:
623
+ compact_steps = build_compact_steps_from_live_trace(collected_steps)
624
+ if not compact_steps and collected_steps:
625
+ nav_only = _first_navigate_step_from_live_trace(collected_steps)
626
+ if nav_only:
627
+ compact_steps = [nav_only]
628
+ return compact_steps
629
+
630
+
631
+ def save_browser_use_run_yaml(
632
+ out_dir: Path,
633
+ goal: str,
634
+ *,
635
+ success: bool,
636
+ summary: str,
637
+ session_id: Optional[str],
638
+ history: Any = None,
639
+ collected_steps: Optional[List[Dict[str, Any]]] = None,
640
+ live_trace_source: str = "history",
641
+ compact_steps: Optional[List[Dict[str, Any]]] = None,
642
+ checks: Optional[List[Dict[str, Any]]] = None,
643
+ ) -> tuple[Optional[str], List[Dict[str, Any]]]:
644
+ """Write ``workflow.yaml`` into the run directory. Returns (path or None, compact steps)."""
645
+ goal = (goal or "").strip()
646
+ if not goal:
647
+ return None, []
648
+ out_dir.mkdir(parents=True, exist_ok=True)
649
+ now = datetime.now(timezone.utc)
650
+ path = out_dir / "workflow.yaml"
651
+ if not compact_steps:
652
+ compact_steps = build_compact_steps(history, collected_steps)
653
+ summary_s = summary if isinstance(summary, str) else str(summary)
654
+ if history is not None:
655
+ try:
656
+ execution = build_execution_context_for_yaml(
657
+ history, success=success, summary=summary_s, compact_steps=compact_steps
658
+ )
659
+ except Exception as e:
660
+ logger.warning("build_execution_context_for_yaml failed: %s", e)
661
+ execution = build_execution_context_for_live_trace(
662
+ success=success, summary=summary_s, compact_steps=compact_steps, source=live_trace_source
663
+ )
664
+ else:
665
+ execution = build_execution_context_for_live_trace(
666
+ success=success, summary=summary_s, compact_steps=compact_steps, source=live_trace_source
667
+ )
668
+ doc: Dict[str, Any] = {
669
+ "format": FORMAT_V2_COMPACT,
670
+ "goal": goal,
671
+ "created_at": now.isoformat().replace("+00:00", "Z"),
672
+ "success": success,
673
+ "summary": summary_s,
674
+ "session_id": session_id,
675
+ "steps_count": len(compact_steps),
676
+ "steps": _sanitize_jsonish(compact_steps),
677
+ "execution": execution,
678
+ }
679
+ if checks:
680
+ doc["checks"] = _sanitize_jsonish(checks)
681
+ if history is not None and _truthy_include_full_history():
682
+ doc["history"] = serialize_browser_use_history(history)
683
+ try:
684
+ with open(path, "w", encoding="utf-8") as f:
685
+ yaml.dump(doc, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
686
+ return str(path.resolve()), compact_steps
687
+ except Exception as e:
688
+ logger.warning("Could not save the workflow YAML: %s", e)
689
+ return None, compact_steps