@capagents/forgeqe 1.2.0 → 1.3.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 +54 -3
- package/README.md +1 -1
- package/docs/COST_ARCHITECTURE.md +280 -0
- package/docs/feature-test-prompts.md +95 -0
- package/docs/user-guide.html +66 -8
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/brain.md +36 -4
- package/prompts/browser-api.md +13 -0
- package/prompts/browser-capture.md +2 -2
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/api_step.py +130 -0
- package/py/forgeqe_engine/capture.py +53 -2
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +70 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +177 -5
- package/src/api/fromSteps.ts +111 -0
- package/src/capabilities/registry.ts +19 -5
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- package/src/cases/quality.ts +3 -1
- package/src/cases/readiness.ts +35 -6
- package/src/cases/save.ts +241 -48
- package/src/cases/types.ts +11 -0
- package/src/cli/help.ts +8 -0
- package/src/cli/index.ts +64 -1
- package/src/code/coverage.ts +22 -3
- package/src/code/decisions.ts +73 -0
- package/src/code/find.ts +3 -1
- package/src/code/graph/graph.ts +33 -2
- package/src/code/handoff.ts +20 -0
- package/src/code/loop.ts +27 -1
- package/src/code/notes.ts +48 -1
- package/src/code/prompt.ts +31 -9
- package/src/code/protect.ts +103 -0
- package/src/code/replay.ts +33 -0
- package/src/code/review.ts +2 -0
- package/src/code/runtime.ts +35 -3
- package/src/code/shortlist.ts +309 -0
- package/src/code/testrun.ts +3 -1
- package/src/code/traceability.ts +2 -0
- package/src/commands.ts +6 -1
- package/src/config.ts +46 -0
- package/src/engine/bridge.ts +47 -5
- package/src/mcp/casetools.ts +107 -11
- package/src/mcp/commands.ts +74 -20
- package/src/mcp/packtools.ts +187 -0
- package/src/mcp/tools.ts +238 -29
- package/src/pack/collect.ts +251 -0
- package/src/pack/index.ts +134 -0
- package/src/pack/init.ts +45 -0
- package/src/pack/pack.ts +327 -0
- package/src/pack/pipeline.ts +373 -0
- package/src/pack/run.ts +155 -0
- package/src/registry/db.ts +249 -0
- package/src/registry/index.ts +72 -0
- package/src/registry/resolve.ts +57 -0
- package/src/registry/tier0.ts +81 -0
- package/src/report/data.ts +7 -0
- package/src/report/html.ts +14 -0
- package/src/report/template.html +52 -48
- package/src/report/usage.ts +113 -0
- package/src/run.ts +32 -2
- package/src/suite/report.ts +22 -4
- package/src/suite/run.ts +13 -5
- package/src/summary.ts +6 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +90 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/tui/transcript.tsx +5 -4
- package/src/types.ts +11 -2
package/py/forgeqe_engine/llm.py
CHANGED
|
@@ -6,7 +6,7 @@ import logging
|
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
8
|
import time
|
|
9
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger("forgeqe_engine.service")
|
|
12
12
|
|
|
@@ -157,18 +157,46 @@ def _build_llm(llm_config: Optional[Dict[str, Any]]):
|
|
|
157
157
|
raise ValueError(f"Unsupported LLM provider for the engine: {provider}")
|
|
158
158
|
|
|
159
159
|
|
|
160
|
-
def
|
|
161
|
-
"""
|
|
160
|
+
def call_usage(result: Any) -> Dict[str, int]:
|
|
161
|
+
"""One model call's tokens from its completion (browser-use ChatInvokeUsage); prompt tokens include cached ones."""
|
|
162
|
+
u = getattr(result, "usage", None)
|
|
163
|
+
if u is None:
|
|
164
|
+
return {}
|
|
165
|
+
out = {"input": int(getattr(u, "prompt_tokens", 0) or 0), "output": int(getattr(u, "completion_tokens", 0) or 0)}
|
|
166
|
+
cached = int(getattr(u, "prompt_cached_tokens", 0) or 0)
|
|
167
|
+
written = int(getattr(u, "prompt_cache_creation_tokens", 0) or 0)
|
|
168
|
+
if cached:
|
|
169
|
+
out["cached"] = cached
|
|
170
|
+
if written:
|
|
171
|
+
out["cache_write"] = written
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _time_llm_calls(llm: Any, calls: List[Tuple[float, float]], on_call: Optional[Callable[[Dict[str, Any]], None]] = None) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Record when each model call started and ended, so the report can split step time into model and browser;
|
|
178
|
+
on_call also gets each call's time and tokens (counts only, never the prompt), for ForgeQE's usage ledger.
|
|
179
|
+
"""
|
|
162
180
|
inner = getattr(llm, "ainvoke", None)
|
|
163
181
|
if inner is None:
|
|
164
182
|
return
|
|
165
183
|
|
|
166
184
|
async def timed(*args: Any, **kwargs: Any) -> Any:
|
|
167
185
|
started = time.time()
|
|
186
|
+
result: Any = None
|
|
187
|
+
ok = False
|
|
168
188
|
try:
|
|
169
|
-
|
|
189
|
+
result = await inner(*args, **kwargs)
|
|
190
|
+
ok = True
|
|
191
|
+
return result
|
|
170
192
|
finally:
|
|
171
|
-
|
|
193
|
+
ended = time.time()
|
|
194
|
+
calls.append((started, ended))
|
|
195
|
+
if on_call is not None:
|
|
196
|
+
try:
|
|
197
|
+
on_call({"ms": int((ended - started) * 1000), "ok": ok, **call_usage(result)})
|
|
198
|
+
except Exception:
|
|
199
|
+
logger.debug("model call usage not reported", exc_info=True)
|
|
172
200
|
|
|
173
201
|
try:
|
|
174
202
|
object.__setattr__(llm, "ainvoke", timed)
|
|
@@ -5,6 +5,7 @@ Layout under the memory directory:
|
|
|
5
5
|
|
|
6
6
|
pages/<host>/<page>.json every interactive element seen on that page, each with ranked locators
|
|
7
7
|
flows/<goal>-<hash>.json the actions of the last successful run of a goal, replayable without the LLM
|
|
8
|
+
cases/<hash>.json for a test case: which flow it last passed with, so an edited case replays its unchanged steps
|
|
8
9
|
|
|
9
10
|
A locator's confidence (1-99 %) multiplies four factors: how stable its kind is (test id > id >
|
|
10
11
|
role+name > ... > xpath), how unique it was on the page, how consistently it reappeared on later
|
|
@@ -650,22 +651,44 @@ class MemoryStore:
|
|
|
650
651
|
replayed: int = 0,
|
|
651
652
|
llm_steps: int = 0,
|
|
652
653
|
resume: Optional[Dict[str, Any]] = None,
|
|
654
|
+
case: Optional[Dict[str, Any]] = None,
|
|
655
|
+
checks: Optional[List[Dict[str, Any]]] = None,
|
|
653
656
|
) -> Optional[str]:
|
|
654
657
|
"""
|
|
655
658
|
Update the goal's flow after a run. Only successful runs replace its steps. Returns "saved" or "updated".
|
|
656
659
|
|
|
657
660
|
A failed run with `resume` (the steps that worked before it went wrong) keeps them under "resume", so the
|
|
658
661
|
next run of this task replays them and the agent picks up where the failed run went wrong.
|
|
662
|
+
|
|
663
|
+
`case` ({id, keys, ends}) is the test case the goal came from: a key per case step, and for each the number
|
|
664
|
+
of flow steps done by the end of it (None where unknown). A successful run records it and points the case's
|
|
665
|
+
index at this flow, so a run of the case after an edit replays the steps before the first one that changed.
|
|
659
666
|
"""
|
|
660
667
|
if status == "stopped":
|
|
661
668
|
return None
|
|
662
669
|
with _locked(self.root):
|
|
663
670
|
return self._save_flow_result(goal, start_url, status=status, steps=steps, summary=summary,
|
|
664
|
-
final_url=final_url, replayed=replayed, llm_steps=llm_steps, resume=resume
|
|
671
|
+
final_url=final_url, replayed=replayed, llm_steps=llm_steps, resume=resume, case=case,
|
|
672
|
+
checks=checks)
|
|
673
|
+
|
|
674
|
+
def case_file(self, case_id: str) -> Path:
|
|
675
|
+
return self.root / "cases" / f"{_sha(case_id, 16)}.json"
|
|
676
|
+
|
|
677
|
+
def load_case_flow(self, case_id: str) -> Optional[Dict[str, Any]]:
|
|
678
|
+
"""The flow a test case last passed with, when it recorded where each case step ended."""
|
|
679
|
+
index = _read_json(self.case_file(case_id)) if case_id else None
|
|
680
|
+
name = str((index or {}).get("flow") or "")
|
|
681
|
+
if not name or "/" in name or "\\" in name:
|
|
682
|
+
return None
|
|
683
|
+
flow = _read_json(self.root / "flows" / name)
|
|
684
|
+
if not flow or flow.get("case_id") != case_id or not flow.get("successes") or not isinstance(flow.get("steps"), list):
|
|
685
|
+
return None
|
|
686
|
+
return flow
|
|
665
687
|
|
|
666
688
|
def _save_flow_result(self, goal: str, start_url: Optional[str], *, status: str, steps: List[Dict[str, Any]],
|
|
667
689
|
summary: str, final_url: str, replayed: int, llm_steps: int,
|
|
668
|
-
resume: Optional[Dict[str, Any]] = None
|
|
690
|
+
resume: Optional[Dict[str, Any]] = None, case: Optional[Dict[str, Any]] = None,
|
|
691
|
+
checks: Optional[List[Dict[str, Any]]] = None) -> Optional[str]:
|
|
669
692
|
file = self.flow_file(goal, start_url)
|
|
670
693
|
flow = self.load_flow(goal, start_url)
|
|
671
694
|
created = flow is None
|
|
@@ -689,6 +712,18 @@ class MemoryStore:
|
|
|
689
712
|
flow["llm_steps"] = llm_steps
|
|
690
713
|
flow["replayed_steps"] = replayed
|
|
691
714
|
flow.pop("resume", None)
|
|
715
|
+
# checks: what the run checked, with the flow step each came after (observe.py); None keeps the flow's.
|
|
716
|
+
# New steps from the agent with no checks given leave none, since old positions would not fit them.
|
|
717
|
+
if checks is not None:
|
|
718
|
+
flow["checks"] = checks
|
|
719
|
+
elif llm_steps and flow.get("checks"):
|
|
720
|
+
flow.pop("checks", None)
|
|
721
|
+
if case and case.get("id") and case.get("keys"):
|
|
722
|
+
kept = len(flow["steps"])
|
|
723
|
+
flow["case_id"] = str(case["id"])
|
|
724
|
+
flow["case_step_keys"] = [str(k) for k in case["keys"]]
|
|
725
|
+
flow["case_step_ends"] = [e if isinstance(e, int) and 0 < e <= kept else None for e in (case.get("ends") or [])][: len(case["keys"])]
|
|
726
|
+
_write_json(self.case_file(str(case["id"])), {"case_id": str(case["id"]), "flow": file.name, "updated": now})
|
|
692
727
|
elif status != "complete":
|
|
693
728
|
flow["failures"] = int(flow.get("failures") or 0) + 1
|
|
694
729
|
if keep_resume and resume:
|
|
@@ -702,6 +737,28 @@ class MemoryStore:
|
|
|
702
737
|
return None
|
|
703
738
|
return "saved" if created else "updated"
|
|
704
739
|
|
|
740
|
+
@staticmethod
|
|
741
|
+
def case_prefix(flow: Dict[str, Any], keys: List[str]) -> Tuple[int, int]:
|
|
742
|
+
"""
|
|
743
|
+
How much of a case's earlier flow still fits its steps now: (case steps, flow steps). The case steps are the
|
|
744
|
+
leading ones whose keys did not change, up to the last of them where the flow recorded its end. (0, 0) when none.
|
|
745
|
+
|
|
746
|
+
An end is trusted only when the next step that recorded one ended later: when the agent did the actions of two
|
|
747
|
+
steps before recording either check, both ends are the same, and replaying up to the first would also redo
|
|
748
|
+
the second step's actions, which may be the step that changed.
|
|
749
|
+
"""
|
|
750
|
+
old = [str(k) for k in (flow.get("case_step_keys") or [])]
|
|
751
|
+
ends = [e if isinstance(e, int) and 0 < e <= len(flow.get("steps") or []) else None for e in (flow.get("case_step_ends") or [])]
|
|
752
|
+
same = 0
|
|
753
|
+
while same < min(len(old), len(keys)) and old[same] == keys[same]:
|
|
754
|
+
same += 1
|
|
755
|
+
for n in range(same, 0, -1):
|
|
756
|
+
end = ends[n - 1] if n - 1 < len(ends) else None
|
|
757
|
+
later = next((e for e in ends[n:] if e is not None), None)
|
|
758
|
+
if end is not None and (later is None or later > end):
|
|
759
|
+
return n, end
|
|
760
|
+
return 0, 0
|
|
761
|
+
|
|
705
762
|
def recent_flows(self, host: str, *, exclude: Optional[Path] = None, limit: int = 2) -> List[Dict[str, Any]]:
|
|
706
763
|
folder = self.root / "flows"
|
|
707
764
|
if not folder.is_dir():
|
|
@@ -1001,4 +1058,15 @@ def _compact_step(step: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1001
1058
|
return {"op": "back"}
|
|
1002
1059
|
if action == "evaluate":
|
|
1003
1060
|
return {"op": "evaluate", "code": str(p.get("code") or "")[:2000]}
|
|
1061
|
+
if action == "api_request":
|
|
1062
|
+
# An HTTP call made in the page's session: what was sent, and what the server answered when the run made it.
|
|
1063
|
+
out = {"op": "api", "method": str(p.get("method") or "GET").upper(), "url": p.get("url") or ""}
|
|
1064
|
+
for k in ("body", "content_type", "csrf"):
|
|
1065
|
+
if p.get(k):
|
|
1066
|
+
out[k] = p[k]
|
|
1067
|
+
answer = step.get("api") or {}
|
|
1068
|
+
for k in ("status", "final_url", "redirected", "snippet"):
|
|
1069
|
+
if answer.get(k) not in (None, "", False):
|
|
1070
|
+
out[k] = answer[k]
|
|
1071
|
+
return out
|
|
1004
1072
|
return {"op": "unknown", "action_keys": [action]}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Replaying a flow without the model, and knowing it still passes (docs/COST_ARCHITECTURE.md, phase 2).
|
|
3
|
+
|
|
4
|
+
Two parts:
|
|
5
|
+
- read_only_script: whether an `evaluate` step in a flow only read the page. The agent often runs a script to look at
|
|
6
|
+
the page (list the fields, read their values) before it decides; replay can leave such a step out. The test is on
|
|
7
|
+
the script's source and conservative: anything that could write (assign, call a method that changes the page or the
|
|
8
|
+
browser's state, send a request, schedule code) counts as writing, and the step then needs the agent as before.
|
|
9
|
+
- checks_for_flow / flow_checks_hold: the results a passing run recorded (record_check), kept in its flow with the
|
|
10
|
+
step each was recorded after, and checked again on the replayed page with no model.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
# Anything that writes to the page, the browser, or the network, or runs code later. A script with any of these
|
|
19
|
+
# is not read-only. False positives only cost a model call (today's path); a false negative could skip a real step,
|
|
20
|
+
# so the list leans wide.
|
|
21
|
+
_WRITES = re.compile(
|
|
22
|
+
r"""
|
|
23
|
+
(?<![=!<>])=(?![=>]) # any assignment (=, +=, -=, ...), not ==, ===, =>, <=, >=, !=
|
|
24
|
+
| \+\+ | -- # increments
|
|
25
|
+
| \b(?:delete)\b
|
|
26
|
+
| \.(?:click|submit|requestSubmit|reset|focus|blur|select|setSelectionRange|setRangeText|showModal|show|close|open|
|
|
27
|
+
dispatchEvent|remove|removeChild|append|appendChild|prepend|insertBefore|insertAdjacent\w*|replaceChild|
|
|
28
|
+
replaceWith|replaceChildren|before|after|setAttribute\w*|removeAttribute\w*|toggleAttribute|
|
|
29
|
+
scroll\w*|play|pause|load|reload|assign|pushState|replaceState|back|forward|go|
|
|
30
|
+
setItem|removeItem|clear|add|toggle|set|setProperty|removeProperty|attachShadow|
|
|
31
|
+
sendBeacon|postMessage|writeText|write|writeln|execCommand|requestFullscreen|exitFullscreen)\s*\(
|
|
32
|
+
| \b(?:fetch|XMLHttpRequest|WebSocket|EventSource|eval|Function|setTimeout|setInterval|requestAnimationFrame|
|
|
33
|
+
queueMicrotask|importScripts|import)\s*\(
|
|
34
|
+
| \bnew\s+(?:\w*Event|XMLHttpRequest|WebSocket|Worker|SharedWorker|Function|MutationObserver|BroadcastChannel)\b
|
|
35
|
+
| \b(?:document\.cookie|location|indexedDB|caches|navigator\.serviceWorker)\b
|
|
36
|
+
""",
|
|
37
|
+
re.VERBOSE,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Declarations (const x = ..., let x = ..., var x = ..., function parameters with defaults) write only local names.
|
|
41
|
+
_DECLARATION = re.compile(r"\b(?:const|let|var)\s+(?:[A-Za-z_$][\w$]*|\{[^}]*\}|\[[^\]]*\])\s*=(?![=>])")
|
|
42
|
+
# Text inside string and template literals: an "=" or ".click(" in a selector or a message is not code.
|
|
43
|
+
_STRINGS = re.compile(r"""'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`""", re.DOTALL)
|
|
44
|
+
# Comments.
|
|
45
|
+
_COMMENTS = re.compile(r"//[^\n]*|/\*.*?\*/", re.DOTALL)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_only_script(code: str) -> bool:
|
|
49
|
+
"""Whether a script only reads the page: no assignment to anything but its own new local names, no call that changes
|
|
50
|
+
the page, the browser, or storage, no request, and no code run later."""
|
|
51
|
+
src = str(code or "")
|
|
52
|
+
if not src.strip() or len(src) > 20_000:
|
|
53
|
+
return False
|
|
54
|
+
src = _COMMENTS.sub(" ", src)
|
|
55
|
+
src = _STRINGS.sub('""', src)
|
|
56
|
+
# `const x = ...`: the assignment is the declaration of a local name. Remove the "=" of each declaration only;
|
|
57
|
+
# the expression after it is still checked.
|
|
58
|
+
src = _DECLARATION.sub(lambda m: m.group(0)[:-1] + " ", src)
|
|
59
|
+
# Parameters with defaults, `(a = 1) =>`: rare in these scripts; treated as an assignment, so not read-only.
|
|
60
|
+
return _WRITES.search(src) is None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read_only_step(step: Dict[str, Any]) -> bool:
|
|
64
|
+
"""A flow step replay can leave out: an `evaluate` whose script only reads the page."""
|
|
65
|
+
return step.get("action") == "evaluate" and read_only_script(str((step.get("params") or {}).get("code") or ""))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------------------------
|
|
69
|
+
# Recorded checks
|
|
70
|
+
# ---------------------------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
CHECK_ATTEMPTS = 6
|
|
73
|
+
CHECK_DELAY_SECONDS = 0.5
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def checks_for_flow(checks: List[Dict[str, Any]], at: Dict[str, int]) -> List[Dict[str, Any]]:
|
|
77
|
+
"""The checks a passing run recorded, as its flow keeps them: what to find on the page, and after which flow step."""
|
|
78
|
+
out: List[Dict[str, Any]] = []
|
|
79
|
+
for c in checks or []:
|
|
80
|
+
text = " ".join(str(c.get("text") or "").split())
|
|
81
|
+
if not text:
|
|
82
|
+
continue
|
|
83
|
+
entry: Dict[str, Any] = {"expected": str(c.get("expected") or text)[:200], "text": text[:200], "at": int(at.get(check_key(c), 0))}
|
|
84
|
+
if c.get("url"):
|
|
85
|
+
entry["url"] = str(c["url"])
|
|
86
|
+
if c.get("dynamic"):
|
|
87
|
+
entry["dynamic"] = True
|
|
88
|
+
if c.get("holds") is False:
|
|
89
|
+
entry["holds"] = False
|
|
90
|
+
if isinstance(c.get("step"), int) and c["step"] > 0:
|
|
91
|
+
entry["step"] = c["step"]
|
|
92
|
+
out.append(entry)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_key(c: Dict[str, Any]) -> str:
|
|
97
|
+
return str(c.get("expected") or c.get("text") or "").strip().lower()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def checkable(flow: Dict[str, Any]) -> Optional[str]:
|
|
101
|
+
"""None when the flow's checks can decide a replay on their own; otherwise why not, in words."""
|
|
102
|
+
checks = flow.get("checks")
|
|
103
|
+
if not isinstance(checks, list) or not checks:
|
|
104
|
+
return "the flow has no recorded checks"
|
|
105
|
+
if any(c.get("dynamic") for c in checks):
|
|
106
|
+
return "a recorded check is live data (dynamic), which only the agent can judge"
|
|
107
|
+
if any(c.get("holds") is False for c in checks):
|
|
108
|
+
return "a recorded check did not hold when it was recorded"
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def check_holds(shows: Callable[[str], Awaitable[Optional[bool]]], text: str, attempts: Optional[int] = None, delay: Optional[float] = None) -> Optional[bool]:
|
|
113
|
+
"""Whether the page shows `text`, asking again for a few seconds while it settles; None when it could not be asked."""
|
|
114
|
+
attempts = CHECK_ATTEMPTS if attempts is None else attempts
|
|
115
|
+
delay = CHECK_DELAY_SECONDS if delay is None else delay
|
|
116
|
+
seen: Optional[bool] = None
|
|
117
|
+
for n in range(max(1, attempts)):
|
|
118
|
+
seen = await shows(text)
|
|
119
|
+
if seen:
|
|
120
|
+
return True
|
|
121
|
+
if n + 1 < attempts:
|
|
122
|
+
await asyncio.sleep(delay)
|
|
123
|
+
return seen
|
|
@@ -16,6 +16,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
|
|
16
16
|
|
|
17
17
|
from . import brand
|
|
18
18
|
from .logins import totp_code
|
|
19
|
+
from .observe import check_holds, read_only_step
|
|
19
20
|
from .memory import (
|
|
20
21
|
ELEMENT_ACTIONS,
|
|
21
22
|
READ_ONLY_ACTIONS,
|
|
@@ -45,6 +46,10 @@ class ReplayOutcome:
|
|
|
45
46
|
times: List[Tuple[float, float]] = field(default_factory=list)
|
|
46
47
|
# Steps whose recorded locator no longer matched and another one found the element.
|
|
47
48
|
heals: List[Dict[str, Any]] = field(default_factory=list)
|
|
49
|
+
# The flow's recorded checks found on the page again, each after the step it was recorded after (observe.py).
|
|
50
|
+
checked: List[Dict[str, Any]] = field(default_factory=list)
|
|
51
|
+
# Steps left out because they only read the page (an `evaluate` with a read-only script).
|
|
52
|
+
skipped_reads: int = 0
|
|
48
53
|
|
|
49
54
|
@property
|
|
50
55
|
def complete(self) -> bool:
|
|
@@ -86,7 +91,13 @@ async def replay_flow(
|
|
|
86
91
|
cancel_event: asyncio.Event,
|
|
87
92
|
on_step: Optional[Callable[[], Awaitable[None]]] = None,
|
|
88
93
|
overlay: Any = None,
|
|
94
|
+
checks_first: bool = False,
|
|
89
95
|
) -> ReplayOutcome:
|
|
96
|
+
"""
|
|
97
|
+
`checks_first` (registry.mode on, docs/COST_ARCHITECTURE.md): steps that only read the page are left out, and the
|
|
98
|
+
flow's recorded checks are found on the page again after the step each was recorded after. A check that does not
|
|
99
|
+
hold stops the replay there, so the agent takes over on that page as after any other stop.
|
|
100
|
+
"""
|
|
90
101
|
from browser_use import Tools
|
|
91
102
|
|
|
92
103
|
tools = Tools()
|
|
@@ -99,6 +110,23 @@ async def replay_flow(
|
|
|
99
110
|
out.reason = reason
|
|
100
111
|
return out
|
|
101
112
|
|
|
113
|
+
checks = [c for c in (flow.get("checks") or []) if isinstance(c, dict) and c.get("text")] if checks_first else []
|
|
114
|
+
|
|
115
|
+
async def verify_checks(after: int) -> Optional[str]:
|
|
116
|
+
"""Finds again the checks recorded after flow step `after`; the first that does not hold, in words."""
|
|
117
|
+
due = [c for c in checks if (int(c.get("at") or 0) == after) or (after == len(steps) and not 0 < int(c.get("at") or 0) <= len(steps))]
|
|
118
|
+
if not due:
|
|
119
|
+
return None
|
|
120
|
+
from .checks import page_shows
|
|
121
|
+
|
|
122
|
+
for c in due:
|
|
123
|
+
holds = await check_holds(lambda t: page_shows(browser, t), str(c["text"]))
|
|
124
|
+
out.checked.append({**c, "holds_now": bool(holds)})
|
|
125
|
+
emit({"type": "result", "step": after, "items": [{"ok": bool(holds), "done": False, "text": f'check "{c.get("expected") or c["text"]}": {"shows" if holds else "does not show"} "{c["text"]}"'[:240]}]})
|
|
126
|
+
if not holds:
|
|
127
|
+
return f'the recorded check "{c.get("expected") or c["text"]}" does not hold: the page does not show "{c["text"]}"'
|
|
128
|
+
return None
|
|
129
|
+
|
|
102
130
|
for n, step in enumerate(steps, 1):
|
|
103
131
|
if cancel_event.is_set():
|
|
104
132
|
stop(n, "stopped by user")
|
|
@@ -106,6 +134,17 @@ async def replay_flow(
|
|
|
106
134
|
action = str(step.get("action") or "")
|
|
107
135
|
params = dict(step.get("params") or {})
|
|
108
136
|
line = str(step.get("line") or action)
|
|
137
|
+
if checks_first and read_only_step(step):
|
|
138
|
+
# The agent looked at the page here; replay has nothing to do. Kept in the flow so check positions hold.
|
|
139
|
+
out.done.append(step)
|
|
140
|
+
out.times.append((time.time(), time.time()))
|
|
141
|
+
out.emitted = n
|
|
142
|
+
out.skipped_reads += 1
|
|
143
|
+
failed = await verify_checks(n)
|
|
144
|
+
if failed:
|
|
145
|
+
stop(n + 1 if n < len(steps) else n, failed)
|
|
146
|
+
break
|
|
147
|
+
continue
|
|
109
148
|
if action not in REPLAYABLE_ACTIONS:
|
|
110
149
|
stop(n, f"`{line}` needs the agent")
|
|
111
150
|
break
|
|
@@ -188,6 +227,10 @@ async def replay_flow(
|
|
|
188
227
|
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
228
|
if on_step is not None:
|
|
190
229
|
await on_step()
|
|
230
|
+
failed = await verify_checks(n)
|
|
231
|
+
if failed:
|
|
232
|
+
stop(n + 1 if n < len(steps) else n, failed)
|
|
233
|
+
break
|
|
191
234
|
|
|
192
235
|
out.url = await _current_url(browser)
|
|
193
236
|
return out
|
|
@@ -197,11 +240,14 @@ def handoff_task(goal: str, flow: Dict[str, Any], outcome: ReplayOutcome, *, kin
|
|
|
197
240
|
"""
|
|
198
241
|
The agent's task after a replay: the goal, what already happened, and where to pick up.
|
|
199
242
|
|
|
200
|
-
`kind` is "exact" (an earlier successful run of this task), "
|
|
243
|
+
`kind` is "exact" (an earlier successful run of this task), "prefix" (the recorded steps of a test case's unchanged
|
|
244
|
+
first steps, after the case was edited), "resume" (the steps that worked in a run of this
|
|
201
245
|
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
246
|
"""
|
|
203
247
|
when = str(flow.get("last_success") or flow.get("at") or "")[:10]
|
|
204
|
-
if kind == "
|
|
248
|
+
if kind == "prefix":
|
|
249
|
+
source = "an earlier successful run of this test case, before its later steps were changed"
|
|
250
|
+
elif kind == "resume":
|
|
205
251
|
source = "an earlier run of this task that went wrong later"
|
|
206
252
|
elif kind == "interrupted":
|
|
207
253
|
source = "an earlier run of this task that was cut off before it finished"
|
|
@@ -221,6 +267,13 @@ def handoff_task(goal: str, flow: Dict[str, Any], outcome: ReplayOutcome, *, kin
|
|
|
221
267
|
f"All recorded steps ran and the browser is on {outcome.url or 'the final page'}. Check this page: "
|
|
222
268
|
"if the task is complete, call done right away and report the result. Otherwise continue from here."
|
|
223
269
|
)
|
|
270
|
+
elif kind == "prefix":
|
|
271
|
+
n = int(flow.get("case_steps_done") or 0)
|
|
272
|
+
lines.append(
|
|
273
|
+
f"They did test steps 1 to {n}, which have not changed. The browser is on {outcome.url or 'the current page'}. "
|
|
274
|
+
f"Check that the page agrees, then continue with test step {n + 1}. In your step report, report steps 1 to {n} "
|
|
275
|
+
"from what the page shows now."
|
|
276
|
+
)
|
|
224
277
|
elif kind == "interrupted":
|
|
225
278
|
lines.append(
|
|
226
279
|
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'}. "
|