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