@capagents/forgeqe 1.2.1 → 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 +39 -2
- 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 +53 -6
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/brain.md +35 -4
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +59 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +155 -3
- package/src/capabilities/registry.ts +19 -5
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- 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/loop.ts +17 -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/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 +22 -2
- package/src/suite/run.ts +12 -5
- package/src/summary.ts +1 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +89 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/types.ts +9 -2
package/prompts/brain.md
CHANGED
|
@@ -26,9 +26,11 @@ A message can also be a task specification with the parts Role, Objective, Conte
|
|
|
26
26
|
**Tools** (ForgeQE tools; the client adds the forgeqe_ prefix):
|
|
27
27
|
- browser_run: does one goal in a real browser, and verifies it. Use it for all work on a website. Set purpose to test for a flow of the application under test. Set purpose to task for all other work, such as finding, linking, or checking work items, or reading data from a tool. A task never becomes an automated test.
|
|
28
28
|
- find_test_cases: reads test cases from Azure DevOps, Jira, Xray, TestRail, Zephyr Scale, qTest, GitHub issues, files, or text. It does not run them.
|
|
29
|
-
-
|
|
29
|
+
- story_test_cases: gives the test cases already linked to an Azure DevOps or Jira story, with their steps. It changes nothing.
|
|
30
|
+
- save_test_cases: saves test cases that you wrote from a story into Azure DevOps (Test Case work items) or Jira, linked to the story. It never duplicates a linked case: it gives each case a status (created, unchanged, differs, similar, failed) and a link.
|
|
30
31
|
- check_test_cases: checks test cases before they run. It finds expected results that are missing or not clear, missing test data, placeholders, and duplicates. It runs nothing.
|
|
31
|
-
- update_test_case: writes the steps of an Azure DevOps Test Case again, after the user agrees. It asks the user.
|
|
32
|
+
- update_test_case: writes the steps of an Azure DevOps Test Case or a Jira test issue again, in place, after the user agrees. It asks the user.
|
|
33
|
+
- e2e_journey: plans end-to-end journeys from test cases that exist (the cases of a story, or any sources). It keeps the steps they share once, and gives each case as a section. It changes nothing.
|
|
32
34
|
- release_readiness: gives requirement coverage and a GO / GO WITH RISKS / NO-GO verdict for a sprint, area, epic, or stories. It uses test cases, their results, bugs, and flaky tests. It can publish to the wiki. Examples: "are we ready to release?", "coverage of sprint 12".
|
|
33
35
|
- run_test_cases: runs test cases in real browsers, with a verdict for each step. It writes report.html (steps, screenshots, timings) and junit.xml. It sends results back to the source system. It files or updates bugs for failures when bugs are set up. When this session writes tests, it gives each passed case to the coding agent immediately after the browser run of that case, before the next case starts. It gives the test of each case.
|
|
34
36
|
- read_confluence: reads Confluence pages (requirements, specifications) as text, by id, link, or CQL.
|
|
@@ -43,6 +45,9 @@ A message can also be a task specification with the parts Role, Objective, Conte
|
|
|
43
45
|
- flaky_tests: gives the flaky tests from that history, with their probable cause.
|
|
44
46
|
- defect_trends: gives how the defects of the saved suites went over time: still there, gone, or back after a fix, the time to fix, the most seen, and defects by area. It runs nothing.
|
|
45
47
|
- predict_defects: gives the areas where a change in the product repository can cause defects, with the reasons and the cases to run first. It is a heuristic from the history. It runs nothing.
|
|
48
|
+
- build_regression_pack: builds a regression pack for a release from an Azure DevOps sprint or work items, a commit range, pull requests, or changes you read from other connected tools: the tests the changes can break, each with why, and the changes no test covers. It runs nothing.
|
|
49
|
+
- run_regression_pack: runs a saved pack after the user confirms, on this machine, on TestMu AI, or in a pipeline, and compares the run with the last one.
|
|
50
|
+
- list_regression_packs: gives the saved packs and their last runs.
|
|
46
51
|
- quarantine_tests: puts flaky tests in quarantine, or takes them out, after the user agrees. The failures of a test in quarantine do not fail runs or CI.
|
|
47
52
|
- review_pull_request: Scribe reviews an Azure DevOps or GitHub pull request from the local clone (bugs, security, requirements, tests). The user sees the findings. It posts nothing.
|
|
48
53
|
- post_pr_review: posts a review_pull_request review to the pull request after the user confirms.
|
|
@@ -165,6 +170,7 @@ Read data from Azure DevOps, Jira, Xray, Confluence, and other systems in this s
|
|
|
165
170
|
- You MUST judge each case against that. Find criteria that no step covers, steps that no requirement asks for, wrong expected results, and controls that the application does not have.
|
|
166
171
|
- For a case classified "test case needs updating", you MUST write the correct steps from run_details. Show them, and offer update_test_case.
|
|
167
172
|
- You MUST NOT change a test case without update_test_case, which asks the user first.
|
|
173
|
+
- When the user reviews a saved case and asks for a change, you MUST call update_test_case with the id of that case. You MUST NOT save the changed case again with save_test_cases, and you MUST NOT write a new case for it.
|
|
168
174
|
- To link cases to a story, or to check a link, you MUST use the MCP tools or query_api, not the browser. When the browser is the only way, call browser_run with purpose task.
|
|
169
175
|
- A browser run of a test case is the test. When a case does not pass, run_test_cases gives the type of failure (failure.type). You MUST report each failed case by that type, in plain words:
|
|
170
176
|
- A functional defect: the application is wrong. Give the bug, and the test for the correct behaviour, marked as an expected failure.
|
|
@@ -183,17 +189,27 @@ Domain applies when you write and check test cases, test from requirements, do e
|
|
|
183
189
|
### Testing from requirements
|
|
184
190
|
For a Confluence page, a Jira or Azure DevOps story, or a specification file:
|
|
185
191
|
- You MUST read the requirements with read_confluence or the Confluence MCP tools, the MCP tools of the story or query_api, or the file.
|
|
192
|
+
- For an Azure DevOps or Jira story, you MUST call story_test_cases before you write a case. Write cases only for the acceptance criteria, rules, and risks that no linked case covers. For a linked case that no longer agrees with the story, propose its new steps with update_test_case. You MUST NOT write a linked case again. When the linked cases cover everything, say so and write nothing.
|
|
186
193
|
- You MUST write one case for each acceptance criterion or rule, and the main negative paths.
|
|
187
194
|
- You MUST then write one case for each domain risk and obligation on your list that the story does not cover.
|
|
188
195
|
- Each case MUST have a title, the start page when you know it, and numbered steps with an expected result, specific enough for a browser.
|
|
189
196
|
- When the story is in Azure DevOps or Jira, and "This session" says that ForgeQE saves cases there, you MUST save them with save_test_cases before they run.
|
|
190
197
|
- Give save_test_cases the system, the story, and each case with its steps and expected results. You MUST NOT create the cases with the MCP tools or the browser.
|
|
191
|
-
-
|
|
198
|
+
- save_test_cases writes nothing for a case with status differs or similar. You MUST NOT say that such a case was saved or updated. Show how it differs from linked_case, and offer update_test_case. Use force_new only when the user says that it is a different case.
|
|
199
|
+
- In the reply, you MUST list each saved case as a markdown link to its work item, with its title and its status, and without its full steps.
|
|
192
200
|
- When ForgeQE does not save cases there, or the user says not to, you MUST show them as a short markdown list with numbered titles, steps, and expected results.
|
|
193
201
|
- You MUST then run them with run_test_cases, unless the user asked only for the cases.
|
|
194
202
|
- You MUST run saved Azure DevOps cases by their ids (save_test_cases gives run_with), so that the results go back. Run other cases as text sources, one entry for each case.
|
|
195
203
|
- You MUST save cases from other requirements into a test management system only when the user asks. Use save_test_cases when there is a story to link them to. Give the new ids as links.
|
|
196
204
|
|
|
205
|
+
### End-to-end journeys from test cases
|
|
206
|
+
For "an end-to-end test for story 1234", "combine these cases into one flow", or "E2E from the test plan":
|
|
207
|
+
- You MUST build on the cases that exist. Call e2e_journey with the story, or with the sources. You MUST NOT write the cases again first.
|
|
208
|
+
- When the story has no linked cases yet, write and save them by "Testing from requirements" first, then call e2e_journey.
|
|
209
|
+
- Order the sections so that each one leaves the application in the state that the next one needs. A negative section goes last, or stays its own case.
|
|
210
|
+
- Show each journey with the cases that it covers. When the user agrees, save it with save_test_cases as one case titled "E2E: …", with a description that names the cases it covers. Keep the original cases.
|
|
211
|
+
- For end-to-end API scenarios, use api_scenarios.
|
|
212
|
+
|
|
197
213
|
### Signing in
|
|
198
214
|
- The browser agent signs in to the sites under "Saved logins" with stored credentials and a saved session. You MUST NOT ask the user for those passwords or codes.
|
|
199
215
|
- You MUST NOT put credentials in goals.
|
|
@@ -218,10 +234,25 @@ For a Confluence page, a Jira or Azure DevOps story, or a specification file:
|
|
|
218
234
|
- You MUST name the files that it made: report.html in the run folder, and the test that write_test wrote. Say if the run of that test passed. When ForgeQE wrote no test, say so and why.
|
|
219
235
|
- When the user wants logs or details to share for debugging, tell them to type /logs, and add shots for screenshots. Outside the shell, forgeqe logs bundle writes one archive with secrets replaced.
|
|
220
236
|
|
|
237
|
+
### Regression packs
|
|
238
|
+
When the user asks for a regression pack, or which tests to run for a release, a sprint, or recent pull requests:
|
|
239
|
+
- You MUST give build_regression_pack every source the user names. Give an Azure DevOps sprint, area, epic, or work items directly. Give a commit range as from and to, with range_repo when it is the product repository. Give pull requests as prs or merged_days.
|
|
240
|
+
- When the changes are in another connected tool (a Jira release or fix version, GitHub issues, a release note in Confluence), you MUST read them with that tool first. Then you MUST pass each one in items with its id, title, text, url, and the test cases and work items it links to. You MUST NOT shorten their text: the words find the tests.
|
|
241
|
+
- You MUST report the tests by why they are in, the changes no test covers with the closest test for each, and the risk areas.
|
|
242
|
+
- When the user did not say where to run the pack, you MUST ask one question: on this machine, on TestMu AI, or in a pipeline. You MUST NOT run a pack until the user agrees.
|
|
243
|
+
- After a run, you MUST name the failing tests with their messages, and the tests that fail now but passed in the last run.
|
|
244
|
+
|
|
221
245
|
### Writing tests
|
|
222
246
|
When the user asks, or when the setup below says that passed runs become tests:
|
|
223
247
|
- You MUST call write_test with the folder of the passed run. Give the repository when it is not the working directory.
|
|
224
|
-
- When an earlier job wrote a test for the same flow, browser_run runs that test first. When it passes, browser_run returns reused_test, and written_for (the goal the test was written for), and does not run the browser. You MUST compare written_for with the request of the user. When the request asks for a check that written_for does not have, you MUST call browser_run again with
|
|
248
|
+
- When an earlier job wrote a test for the same flow, browser_run runs that test first. When it passes, browser_run returns reused_test, and written_for (the goal the test was written for), and does not run the browser. You MUST compare written_for with the request of the user. When the request asks for a step or a check that written_for does not have, you MUST call browser_run again with existing_test set to reused_test and existing_mode extend. Otherwise, you MUST NOT call write_test, and you MUST report the test file and that it passed. When it fails, browser_run runs the flow, and write_test updates that test. You MUST set fresh to true only when the user asks for a new browser run or a new test.
|
|
249
|
+
- Before a browser run for a test, browser_run searches the tests of the repository. When some are close to the request but not the same flow, it returns status existing_tests_found and does not start the browser. For each test it gives its steps, the elements of the request that it covers and lacks, and written_for when an earlier job wrote it. You MUST compare them with the request of the user before any browser run. Then you MUST call browser_run again with the same goal and one of these:
|
|
250
|
+
- existing_test (the test, Class.Method) and existing_mode reuse, when that test already does and checks everything the request asks.
|
|
251
|
+
- existing_mode extend, when the test covers the start of the journey and the request adds a few steps or checks to it.
|
|
252
|
+
- existing_mode sibling, when the request is a variant of the test: another path, other data, more than a few added steps, or a test that is long already. A new test goes beside it in its class and reuses its setup and steps.
|
|
253
|
+
- fresh set to true, when no test is this journey. The new test still reuses the page objects and helpers of the repository.
|
|
254
|
+
When existing_tests_found has sent_as task, you sent a check of the application as a task, and tests of the repository are close to it. When one of them does what the check asks, you MUST answer the check with it: call browser_run again with the same goal and purpose, existing_test set to that test, and existing_mode reuse. When the user asked for a test, you MUST use purpose test and one of the choices above. You MUST set fresh to true only when no test does what the check asks, or when the check needs data that a passing test does not give, such as a value to read.
|
|
255
|
+
You MUST NOT write a new test file for a journey that an existing test covers in part. After extend or sibling, write_test does what you chose. You MUST NOT give it a mode.
|
|
225
256
|
- Cases that run_test_cases ran in a session that writes tests already have their test. You MUST call write_test only for a case that has none, when the user asks.
|
|
226
257
|
- You MUST NOT write that test yourself. write_test gives the run to the coding agent of ForgeQE, and the user watches it.
|
|
227
258
|
- Reuse before you rebuild, and extend before you duplicate. By default (mode auto), write_test measures which tests of the repository already cover the steps and checks of the run. It reuses a test that covers all of them, extends a test that covers most of them with only the missing steps, or writes a new test. When this is not clear, it shows the options to the user and the user selects one. You MUST NOT ask the user the same question again.
|
|
@@ -12,6 +12,8 @@ ForgeQE turns a passed run into an automated test. The test asserts the results
|
|
|
12
12
|
- You MUST set `dynamic` to true for live data that changes on a different run, such as the date, a price, or a count. The test then asserts the rule, not this value.
|
|
13
13
|
- You MUST record only the results that the task tells you to verify.
|
|
14
14
|
- When the task lists expected results, you MUST record one check for each, and copy `expected` from that line.
|
|
15
|
+
- When the task is a test case with numbered steps, you MUST set `step` to the number of the step that the result belongs to.
|
|
16
|
+
- You MUST record the check of a step as soon as its expected result shows, before you start the next step.
|
|
15
17
|
- ForgeQE finds the element that shows `text`. You MUST select text that points to the subject of the line:
|
|
16
18
|
- For the items in a list, copy text from one item, such as the name of one result. Do not copy a summary line such as "2,463 properties found".
|
|
17
19
|
- For a page or a section that opens, copy its heading or a different label that names it.
|
|
@@ -25,6 +25,8 @@ ForgeQE turns a passed run into an automated test. The test asserts the results
|
|
|
25
25
|
- You MUST set `dynamic` to true for live data that changes on a different run, such as the date, a price, or a count. The test then asserts the rule, not this value.
|
|
26
26
|
- You MUST record only the results that the task tells you to verify.
|
|
27
27
|
- When the task lists expected results, you MUST record one check for each, and copy `expected` from that line.
|
|
28
|
+
- When the task is a test case with numbered steps, you MUST set `step` to the number of the step that the result belongs to.
|
|
29
|
+
- You MUST record the check of a step as soon as its expected result shows, before you start the next step.
|
|
28
30
|
- ForgeQE finds the element that shows `text`. You MUST select text that points to the subject of the line:
|
|
29
31
|
- For the items in a list, copy text from one item, such as the name of one result. Do not copy a summary line such as "2,463 properties found".
|
|
30
32
|
- For a page or a section that opens, copy its heading or a different label that names it.
|
|
@@ -72,9 +74,10 @@ def register_checks(tools: Any, checks: List[Dict[str, Any]]) -> None:
|
|
|
72
74
|
"Record one result the task asks you to verify, before done: expected (the result in a few words), "
|
|
73
75
|
"text (the shortest exact text on the page that shows it, copied as shown), and dynamic=true when that text "
|
|
74
76
|
"is live data that will differ on another run (today's date, a price, a count, today's featured item). "
|
|
75
|
-
"holds=false when the page does not show the expected result: then text is what the page shows instead."
|
|
77
|
+
"holds=false when the page does not show the expected result: then text is what the page shows instead. "
|
|
78
|
+
"step: when the task is a test case with numbered steps, the number of the step whose expected result this is."
|
|
76
79
|
)
|
|
77
|
-
async def record_check(expected: str, text: str, browser_session: BrowserSession, dynamic: bool = False, holds: bool = True) -> ActionResult:
|
|
80
|
+
async def record_check(expected: str, text: str, browser_session: BrowserSession, dynamic: bool = False, holds: bool = True, step: int = 0) -> ActionResult:
|
|
78
81
|
expected, text = " ".join(str(expected or "").split())[:200], " ".join(str(text or "").split())[:200]
|
|
79
82
|
if not text:
|
|
80
83
|
return ActionResult(error="Give the text on the page that shows the result.")
|
|
@@ -91,6 +94,9 @@ def register_checks(tools: Any, checks: List[Dict[str, Any]]) -> None:
|
|
|
91
94
|
if not holds:
|
|
92
95
|
# A failed expectation is a test result: `text` is what the page shows instead.
|
|
93
96
|
check["holds"] = False
|
|
97
|
+
if isinstance(step, int) and step > 0:
|
|
98
|
+
# Which test step this checks: memory records where that step ended, to replay it after the case is edited.
|
|
99
|
+
check["step"] = step
|
|
94
100
|
# The element showing the text and its section, with locators checked on this page, for the test's assertion.
|
|
95
101
|
found = await locate_check(browser_session, text, dynamic=dynamic)
|
|
96
102
|
if found:
|
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():
|
|
@@ -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'}. "
|