@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.
- 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 +781 -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 +146 -0
- package/src/update.ts +311 -0
- 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
|