@capagents/forgeqe 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/HOW_TO_USE.md +40 -3
  2. package/README.md +1 -1
  3. package/docs/COST_ARCHITECTURE.md +280 -0
  4. package/docs/feature-test-prompts.md +95 -0
  5. package/docs/user-guide.html +54 -7
  6. package/evals/prompts/brain/route-api-scenarios.yaml +9 -0
  7. package/evals/prompts/brain/route-e2e.yaml +10 -0
  8. package/evals/prompts/brain/route-flaky.yaml +9 -0
  9. package/evals/prompts/brain/route-heal.yaml +9 -0
  10. package/evals/prompts/brain/route-regression-pack.yaml +9 -0
  11. package/evals/prompts/brain/route-review-update.yaml +10 -0
  12. package/evals/prompts/brain/route-run-cases.yaml +9 -0
  13. package/evals/prompts/brain/route-story-cases.yaml +12 -0
  14. package/forgeqe.example.yaml +15 -0
  15. package/package.json +1 -1
  16. package/prompts/README.md +30 -0
  17. package/prompts/brain.md +34 -49
  18. package/prompts/browser-checks.md +2 -0
  19. package/py/forgeqe_engine/checks.py +8 -2
  20. package/py/forgeqe_engine/llm.py +33 -5
  21. package/py/forgeqe_engine/memory.py +59 -2
  22. package/py/forgeqe_engine/observe.py +123 -0
  23. package/py/forgeqe_engine/replay.py +55 -2
  24. package/py/forgeqe_engine/service.py +155 -3
  25. package/src/agents.ts +52 -0
  26. package/src/brain/index.ts +8 -2
  27. package/src/capabilities/registry.ts +48 -6
  28. package/src/cases/ado.ts +1 -1
  29. package/src/cases/journey.ts +61 -0
  30. package/src/cases/readiness.ts +35 -6
  31. package/src/cases/save.ts +241 -48
  32. package/src/cases/types.ts +11 -0
  33. package/src/cli/help.ts +9 -0
  34. package/src/cli/index.ts +71 -1
  35. package/src/code/coverage.ts +22 -3
  36. package/src/code/decisions.ts +73 -0
  37. package/src/code/find.ts +3 -1
  38. package/src/code/graph/graph.ts +33 -2
  39. package/src/code/loop.ts +17 -1
  40. package/src/code/notes.ts +48 -1
  41. package/src/code/prompt.ts +31 -9
  42. package/src/code/protect.ts +103 -0
  43. package/src/code/review.ts +2 -0
  44. package/src/code/runtime.ts +35 -3
  45. package/src/code/shortlist.ts +309 -0
  46. package/src/code/testrun.ts +3 -1
  47. package/src/code/traceability.ts +2 -0
  48. package/src/commands.ts +6 -1
  49. package/src/config.ts +46 -0
  50. package/src/context.ts +75 -0
  51. package/src/engine/bridge.ts +47 -5
  52. package/src/mcp/captools.ts +19 -0
  53. package/src/mcp/casetools.ts +107 -11
  54. package/src/mcp/commands.ts +74 -20
  55. package/src/mcp/packtools.ts +187 -0
  56. package/src/mcp/routing.ts +145 -0
  57. package/src/mcp/server.ts +7 -3
  58. package/src/mcp/tools.ts +241 -29
  59. package/src/pack/collect.ts +251 -0
  60. package/src/pack/index.ts +134 -0
  61. package/src/pack/init.ts +45 -0
  62. package/src/pack/pack.ts +327 -0
  63. package/src/pack/pipeline.ts +373 -0
  64. package/src/pack/run.ts +155 -0
  65. package/src/registry/db.ts +249 -0
  66. package/src/registry/index.ts +72 -0
  67. package/src/registry/resolve.ts +57 -0
  68. package/src/registry/tier0.ts +81 -0
  69. package/src/report/data.ts +7 -0
  70. package/src/report/html.ts +14 -0
  71. package/src/report/template.html +52 -48
  72. package/src/report/usage.ts +113 -0
  73. package/src/run.ts +22 -2
  74. package/src/suite/run.ts +12 -5
  75. package/src/summary.ts +1 -0
  76. package/src/tokenomics/budget.ts +172 -0
  77. package/src/tokenomics/ledger.ts +287 -0
  78. package/src/tui/App.tsx +89 -2
  79. package/src/tui/inspector.tsx +40 -35
  80. package/src/types.ts +9 -2
@@ -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), "resume" (the steps that worked in a run of this
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 == "resume":
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'}. "
@@ -15,6 +15,7 @@ import os
15
15
  import re
16
16
  import time
17
17
  from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
18
19
  from pathlib import Path
19
20
  from typing import Any, Callable, Dict, List, Optional, Set, Tuple
20
21
 
@@ -25,6 +26,7 @@ from .memory import READ_ONLY_ACTIONS, MemoryStore, compact_step, flow_step, pag
25
26
  from .prompts import CONVENTIONS, text as prompt_text, use_prompts
26
27
  from .report import CASE_REPORT_INSTRUCTION, CaseReport, build_timeline, case_report_from_history
27
28
  from .replay import ReplayOutcome, handoff_task, replay_flow
29
+ from .observe import check_key, checkable, checks_for_flow
28
30
  from .settings import (
29
31
  _truthy_env,
30
32
  _browser_use_agent_tuning_kwargs,
@@ -107,6 +109,9 @@ class RunRequest:
107
109
  logins: List[Dict[str, Any]] = field(default_factory=list)
108
110
  # Number of steps in the test case this goal came from; the agent then reports a verdict for each.
109
111
  case_steps: int = 0
112
+ # The test case and a key per step (changes when the step is reworded): an edited case replays its unchanged steps.
113
+ case_id: str = ""
114
+ case_step_keys: List[str] = field(default_factory=list)
110
115
  # Step screenshots for the report: "all", "failures", or "off".
111
116
  screenshots: str = "all"
112
117
  # Accessibility checks on each page visited, for the report.
@@ -139,6 +144,8 @@ class RunRequest:
139
144
  return cls(
140
145
  logins=[x for x in (d.get("logins") or []) if isinstance(x, dict)],
141
146
  case_steps=int(d["case_steps"]) if str(d.get("case_steps") or "").isdigit() else 0,
147
+ case_id=str(d.get("case_id") or ""),
148
+ case_step_keys=[str(k) for k in (d.get("case_step_keys") or []) if str(k)],
142
149
  screenshots=str(d.get("screenshots") or "all").lower(),
143
150
  accessibility=d.get("accessibility") is not False,
144
151
  overlay=d.get("overlay") is not False,
@@ -395,6 +402,41 @@ def serialize_browser_step_payload(browser_state: Any, agent_output: Any, n_step
395
402
 
396
403
 
397
404
  REPLAY_MODES = ("verify", "trust", "off")
405
+ # checks_first (docs/COST_ARCHITECTURE.md): every Nth replay of a flow is still checked by the agent, so a check that
406
+ # passes when it should not is caught; and a flow not passed for this long is checked by the agent again.
407
+ CANARY_EVERY = 10
408
+ MAX_FLOW_AGE_DAYS = 30
409
+
410
+
411
+ def _int(raw: Any, default: int) -> int:
412
+ try:
413
+ n = int(raw)
414
+ except (TypeError, ValueError):
415
+ return default
416
+ return n if n >= 0 else default
417
+
418
+
419
+ def judged_by_checks(flow: Dict[str, Any], replay: Any, mem: "MemoryRun") -> Optional[Dict[str, Any]]:
420
+ """
421
+ After a complete replay of an exact flow: whether its recorded checks decide the run with no model. ok: every
422
+ check was found on the page again. Otherwise `why` says what leaves it to the agent (today's verify path).
423
+ """
424
+ why = checkable(flow)
425
+ if why:
426
+ return {"ok": False, "why": why}
427
+ if len(replay.checked) < len(flow.get("checks") or []) or not all(c.get("holds_now") for c in replay.checked):
428
+ return {"ok": False, "why": "not every recorded check was found on the page again"}
429
+ replays = int(flow.get("replays") or 0) + 1
430
+ if mem.canary_every and replays % mem.canary_every == 0:
431
+ return {"ok": False, "why": f"replay {replays} of this flow: every {mem.canary_every}th is checked by the agent too"}
432
+ last = str(flow.get("last_success") or "")
433
+ try:
434
+ age = (datetime.now(timezone.utc) - datetime.fromisoformat(last.replace("Z", "+00:00"))).days if last else None
435
+ except ValueError:
436
+ age = None
437
+ if age is None or age > mem.max_age_days:
438
+ return {"ok": False, "why": f"the flow last passed more than {mem.max_age_days} days ago" if age is not None else "the flow has no recorded pass"}
439
+ return {"ok": True, "why": ""}
398
440
 
399
441
 
400
442
  @dataclass
@@ -418,6 +460,59 @@ class MemoryRun:
418
460
  used_checkpoint: str = ""
419
461
  # Actions that worked without changing the page (dead clicks, read-only scripts): not part of the flow.
420
462
  no_effect: List[str] = field(default_factory=list)
463
+ # The test case this run is for, and a key per case step.
464
+ case_id: str = ""
465
+ case_keys: List[str] = field(default_factory=list)
466
+ # Case step number -> flow steps done when its expected result was recorded (the end of that step).
467
+ check_at: Dict[int, int] = field(default_factory=dict)
468
+ # Ends of the case steps a replay already did, carried over from the flow that was replayed.
469
+ carried_ends: List[Optional[int]] = field(default_factory=list)
470
+ # registry.mode on (docs/COST_ARCHITECTURE.md): the flow keeps its checks and a replay is judged by them.
471
+ checks_first: bool = False
472
+ # The checks the replayed flow had, and those the agent recorded this run with the flow step each came after.
473
+ flow_checks: List[Dict[str, Any]] = field(default_factory=list)
474
+ recorded: List[Dict[str, Any]] = field(default_factory=list)
475
+ check_pos: Dict[str, int] = field(default_factory=dict)
476
+ check_text: Dict[str, Any] = field(default_factory=dict)
477
+ canary_every: int = CANARY_EVERY
478
+ max_age_days: int = MAX_FLOW_AGE_DAYS
479
+
480
+ def checks_to_save(self, status: str) -> Optional[List[Dict[str, Any]]]:
481
+ """The checks the flow keeps after this run; None leaves the flow's as they are."""
482
+ if not self.checks_first or status != "complete":
483
+ return None
484
+ new = checks_for_flow(self.recorded, self.check_pos)
485
+ if not self.llm_steps:
486
+ # A replay judged by its checks: the same steps, so the same checks.
487
+ return list(self.flow_checks) if self.flow_checks else None
488
+ # Checks from the replayed part still stand where the agent did not record them again.
489
+ done = len(self.replay.done) if self.replay else 0
490
+ keys = {check_key(c) for c in new}
491
+ carried = [c for c in self.flow_checks if 0 < int(c.get("at") or 0) <= done and check_key(c) not in keys]
492
+ return carried + new
493
+
494
+ def note_checks(self, checks: List[Dict[str, Any]]) -> None:
495
+ """After an agent step: each case step whose check was just recorded ends at the flow as it is now."""
496
+ self.recorded = checks
497
+ for c in checks:
498
+ key = check_key(c)
499
+ # A check recorded again (the same expected result, new text) moves to where it was recorded last.
500
+ if key and self.check_text.get(key) != c.get("text"):
501
+ self.check_pos[key] = len(self.steps)
502
+ self.check_text[key] = c.get("text")
503
+ for c in checks:
504
+ n = c.get("step")
505
+ if isinstance(n, int) and 0 < n <= len(self.case_keys) and n not in self.check_at:
506
+ self.check_at[n] = len(self.steps)
507
+
508
+ def case_ends(self) -> List[Optional[int]]:
509
+ ends: List[Optional[int]] = [None] * len(self.case_keys)
510
+ for i, e in enumerate(self.carried_ends[: len(ends)]):
511
+ ends[i] = e
512
+ for n, at in self.check_at.items():
513
+ if ends[n - 1] is None:
514
+ ends[n - 1] = at
515
+ return ends
421
516
 
422
517
  def checkpoint(self) -> Optional[Dict[str, Any]]:
423
518
  """Save the steps that worked so far and the page memory learned so far, so a run cut off here can be picked up."""
@@ -447,7 +542,14 @@ class MemoryRun:
447
542
  if not folder:
448
543
  return None
449
544
  mode = str((req.memory or {}).get("replay") or "verify").lower()
450
- return cls(MemoryStore(folder), mode if mode in REPLAY_MODES else "verify", req.goal, start_url)
545
+ mem = req.memory or {}
546
+ return cls(
547
+ MemoryStore(folder), mode if mode in REPLAY_MODES else "verify", req.goal, start_url,
548
+ case_id=req.case_id, case_keys=list(req.case_step_keys) if req.case_id else [],
549
+ checks_first=bool(mem.get("checks_first")),
550
+ canary_every=_int(mem.get("canary_every"), CANARY_EVERY),
551
+ max_age_days=_int(mem.get("max_age_days"), MAX_FLOW_AGE_DAYS),
552
+ )
451
553
 
452
554
  def finish(self, out: Dict[str, Any]) -> Dict[str, Any]:
453
555
  self.finished = True
@@ -470,6 +572,8 @@ class MemoryRun:
470
572
  replayed=len(self.replay.done) if self.replay else 0,
471
573
  llm_steps=self.llm_steps,
472
574
  resume=self.resume if status == "fail" else None,
575
+ case={"id": self.case_id, "keys": self.case_keys, "ends": self.case_ends()} if self.case_id and self.case_keys else None,
576
+ checks=self.checks_to_save(status),
473
577
  )
474
578
  except Exception as ex:
475
579
  logger.warning("memory: could not save the flow: %s", ex)
@@ -494,6 +598,11 @@ class MemoryRun:
494
598
  info["repaired"] = {"step": self.replay.stopped_at, "reason": self.replay.reason, "agent_steps": self.llm_steps}
495
599
  if self.replay and self.replay.heals:
496
600
  info["heals"] = self.replay.heals
601
+ if self.replay and self.replay.checked:
602
+ info["checked"] = len(self.replay.checked)
603
+ info["checks_held"] = sum(1 for c in self.replay.checked if c.get("holds_now"))
604
+ if self.replay and self.replay.skipped_reads:
605
+ info["skipped_reads"] = self.replay.skipped_reads
497
606
  if self.resume and status == "fail":
498
607
  info["resume"] = {
499
608
  "steps": len(self.resume["steps"]),
@@ -788,7 +897,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
788
897
  except Exception as e:
789
898
  return {"success": False, "error": str(e), "summary": ""}
790
899
  llm_calls: List[Tuple[float, float]] = []
791
- _time_llm_calls(llm, llm_calls)
900
+ _time_llm_calls(llm, llm_calls, lambda c: emit({"type": "call", **c}))
792
901
  use_prompts(req.prompts)
793
902
  diag = Diagnostics(accessibility=req.accessibility, network=req.network, axe=req.axe or None, vitals=req.vitals, visual=req.visual)
794
903
 
@@ -920,6 +1029,13 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
920
1029
  mem.used_checkpoint = str(checkpoint.get("run_id") or "")
921
1030
  elif saved and isinstance(saved.get("resume"), dict) and saved["resume"].get("steps"):
922
1031
  flow, kind = saved["resume"], "resume"
1032
+ elif mem.case_id and mem.case_keys:
1033
+ # The test case was edited (its goal is new): replay its steps before the first one that changed.
1034
+ earlier = mem.store.load_case_flow(mem.case_id)
1035
+ if earlier and str(earlier.get("start_url") or "") == (normalized_initial or ""):
1036
+ done, end = MemoryStore.case_prefix(earlier, mem.case_keys)
1037
+ if done:
1038
+ flow, kind = {**earlier, "steps": earlier["steps"][:end], "case_steps_done": done}, "prefix"
923
1039
  if flow and kind and flow.get("steps"):
924
1040
  if kind == "interrupted":
925
1041
  replay_text = (
@@ -932,6 +1048,11 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
932
1048
  + (f" ({flow['run_id']})" if flow.get("run_id") else "")
933
1049
  + (f", which went wrong at step {flow['failed_at']}" if flow.get("failed_at") else "")
934
1050
  )
1051
+ elif kind == "prefix":
1052
+ replay_text = (
1053
+ f"The test case changed after step {flow['case_steps_done']}: replaying the {len(flow['steps'])} recorded "
1054
+ f"steps that did steps 1 to {flow['case_steps_done']}, which did not change"
1055
+ )
935
1056
  else:
936
1057
  replay_text = (
937
1058
  f"Replaying {len(flow['steps'])} recorded steps from memory "
@@ -940,13 +1061,19 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
940
1061
  emit({"type": "memory", "text": replay_text})
941
1062
  await browser.start()
942
1063
  await diag.start(browser)
1064
+ mem.flow_checks = [c for c in (flow.get("checks") or []) if isinstance(c, dict)] if mem.checks_first else []
943
1065
  replay = await replay_flow(
944
- flow, browser=browser, store=mem.store, emit=emit, cancel_event=cancel_event, on_step=lambda: diag.sample(browser), overlay=overlay
1066
+ flow, browser=browser, store=mem.store, emit=emit, cancel_event=cancel_event, on_step=lambda: diag.sample(browser), overlay=overlay,
1067
+ checks_first=mem.checks_first and kind == "exact",
945
1068
  )
946
1069
  mem.replay = replay
947
1070
  for done_step in replay.done:
948
1071
  mem.add(done_step)
949
1072
  replay_offset = replay.emitted
1073
+ if replay.complete and kind in ("exact", "prefix"):
1074
+ # The case steps the replay did end where they ended in the recorded run.
1075
+ known = list(flow.get("case_step_ends") or [])
1076
+ mem.carried_ends = known[: int(flow.get("case_steps_done") or len(known))]
950
1077
  if replay.complete and kind == "exact":
951
1078
  emit({"type": "memory", "text": f"Replayed all {len(replay.done)} steps without the LLM"})
952
1079
  elif replay.complete:
@@ -958,6 +1085,30 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
958
1085
  out = {"success": False, "error": err, "summary": err, "step_events": collected_steps, "cancelled": True}
959
1086
  _save(out, "cancelled")
960
1087
  return out
1088
+ judged = judged_by_checks(flow, replay, mem) if replay.complete and kind == "exact" and mem.checks_first and mem.mode == "verify" else None
1089
+ if judged and judged["ok"]:
1090
+ held = replay.checked
1091
+ summary = f"Replayed all {len(replay.done)} recorded steps and found all {len(held)} recorded checks on the page again, without calling the LLM."
1092
+ if flow.get("summary"):
1093
+ summary += f"\n\nResult of the run that recorded them: {flow['summary']}"
1094
+ emit({"type": "memory", "text": f"All {len(held)} recorded checks hold: passed without the LLM"})
1095
+ out = {
1096
+ "success": True,
1097
+ "outcome": "passed",
1098
+ "summary": summary,
1099
+ "result_markdown": f"### Task\n\n{goal}\n\n---\n\n## Result\n\n{summary}",
1100
+ "session_id": req.session_id,
1101
+ "final_url": replay.url,
1102
+ "usage": {"input": 0, "output": 0},
1103
+ "steps": replay.emitted,
1104
+ "step_events": collected_steps,
1105
+ "checks": [{k: v for k, v in c.items() if k in ("expected", "text", "url", "step")} for c in held],
1106
+ "browser": await browser_profile(browser, browser_kw, provider=browser_provider, remote=browser_remote),
1107
+ }
1108
+ _save(out, "memory_checks")
1109
+ return out
1110
+ if judged and not judged["ok"]:
1111
+ emit({"type": "memory", "text": f"The agent checks the result: {judged['why']}"})
961
1112
  if replay.complete and mem.mode == "trust" and kind == "exact":
962
1113
  summary = f"Replayed all {len(replay.done)} recorded steps from memory without calling the LLM."
963
1114
  if flow.get("summary"):
@@ -1141,6 +1292,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
1141
1292
  mem, pending_actions, results, mem.llm_steps, agent_note(getattr(last, "model_output", None)),
1142
1293
  effects=list(action_effects), page_url=step_url["url"],
1143
1294
  )
1295
+ mem.note_checks(recorded_checks)
1144
1296
  try:
1145
1297
  point = mem.checkpoint()
1146
1298
  if point:
package/src/agents.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { SkillAgent } from "./skills/registry.ts"
2
+
3
+ /**
4
+ * Every agent ForgeQE runs, what it loads on every model call, and how much that may be.
5
+ *
6
+ * This is the one list to change when an agent, a prompt, or a skill audience is added: tests/agents.test.ts checks it
7
+ * against the code (every prompt file is loaded by an agent, every agent the code defines is here) and checks each
8
+ * agent's fixed context against its budget, so the cost of a change shows in the build and in `forgeqe context`.
9
+ */
10
+ export type AgentEntry = {
11
+ /** The agent runtime's name for it (AgentSpec name), or "engine" for the browser agent. */
12
+ name: string
13
+ title: string
14
+ /** agent-runtime: a session with tools; engine: the Python browser agent. */
15
+ kind: "agent-runtime" | "engine"
16
+ /** The prompt files it loads (prompts/<name>.md); the fragments they include count too. */
17
+ prompts: string[]
18
+ /** Skills it may load (by their `agents` audience); loaded on demand, so they are not in the fixed context. */
19
+ skills?: SkillAgent
20
+ /** Where the code defines it. */
21
+ defined: string
22
+ /** Most tokens its fixed context (system prompt, and for ForgeQE's agent its tool list) may take. */
23
+ budget: number
24
+ }
25
+
26
+ export const AGENTS: AgentEntry[] = [
27
+ { name: "forgeqe", title: "ForgeQE's agent (shell, forgeqe run, forgeqe mcp)", kind: "agent-runtime", prompts: ["brain"], skills: "forgeqe", defined: "src/brain/index.ts", budget: 26000 },
28
+ { name: "forgeqe-test", title: "Scribe: writes a test from a verified run", kind: "agent-runtime", prompts: ["test-agent"], skills: "scribe", defined: "src/code/runtime.ts", budget: 4900 },
29
+ { name: "forgeqe-read", title: "Scribe, reading the repository before the run arrives", kind: "agent-runtime", prompts: ["test-agent"], skills: "scribe", defined: "src/code/runtime.ts", budget: 4900 },
30
+ { name: "forgeqe-review", title: "Reviewer of Scribe's tests", kind: "agent-runtime", prompts: ["test-reviewer"], skills: "scribe", defined: "src/code/review.ts", budget: 4900 },
31
+ { name: "forgeqe-heal", title: "Scribe healing tests the application outgrew", kind: "agent-runtime", prompts: ["heal"], skills: "scribe", defined: "src/heal/loop.ts", budget: 4200 },
32
+ { name: "forgeqe-find", title: "Finds the repository tests a request means", kind: "agent-runtime", prompts: ["find-tests"], skills: "scribe", defined: "src/code/find.ts", budget: 1700 },
33
+ { name: "forgeqe-pr-review", title: "Reviews pull requests", kind: "agent-runtime", prompts: ["pr-review"], skills: "scribe", defined: "src/code/prreview.ts", budget: 1700 },
34
+ { name: "forgeqe-api", title: "Writes API and contract tests", kind: "agent-runtime", prompts: ["api-tests"], skills: "scribe", defined: "src/api/write.ts", budget: 2450 },
35
+ { name: "forgeqe-api-plan", title: "Plans end-to-end API scenarios", kind: "agent-runtime", prompts: ["api-scenarios"], defined: "src/api/plan.ts", budget: 1600 },
36
+ { name: "forgeqe-explore-plan", title: "Plans exploratory charters", kind: "agent-runtime", prompts: ["explore-charters"], defined: "src/explore/charters.ts", budget: 1650 },
37
+ { name: "forgeqe-defect-rca", title: "Finds the root cause of a defect", kind: "agent-runtime", prompts: ["defect-rca"], defined: "src/cases/rca.ts", budget: 950 },
38
+ { name: "forgeqe-refine", title: "Refine: turns a request into a task specification", kind: "agent-runtime", prompts: ["refine"], defined: "src/refine.ts", budget: 1450 },
39
+ { name: "forgeqe-judge", title: "Judges domain evaluations", kind: "agent-runtime", prompts: ["eval-judge"], defined: "src/evals/domain.ts", budget: 750 },
40
+ {
41
+ name: "engine",
42
+ title: "The browser agent (every browser run and test case)",
43
+ kind: "engine",
44
+ prompts: ["browser-api", "browser-capture", "browser-case-report", "browser-checks", "browser-efficiency", "browser-error-recovery", "browser-extra", "browser-goal-verification", "browser-skill-select", "conventions"],
45
+ skills: "browser",
46
+ defined: "py/forgeqe_engine/service.py",
47
+ budget: 2700,
48
+ },
49
+ ]
50
+
51
+ /** Prompt files that are documentation, not loaded by any agent. */
52
+ export const NOT_LOADED = ["README"]
@@ -8,6 +8,7 @@ import type { AppConfig } from "../config.ts"
8
8
  import { externalMcp, externalNotes, type ExternalPlan } from "../mcp/external.ts"
9
9
  import { MCP_NAME, TOOL_TIMEOUT_MS, localToolServer } from "../mcp/server.ts"
10
10
  import { TOOLS, environmentNotes, type ToolContext } from "../mcp/tools.ts"
11
+ import { createRouter } from "../mcp/routing.ts"
11
12
  import { brainPrompt, BRAIN_SCHEMA, cleanFollowUps, type BrainReport, type FollowUp } from "./prompt.ts"
12
13
  import { redactSecrets } from "../sessionLogins.ts"
13
14
  import { logEvent } from "../sessionLog.ts"
@@ -115,11 +116,16 @@ export function carryOver(turns: { user: string; reply: string }[], text: string
115
116
  ].join("\n")
116
117
  }
117
118
 
119
+ /** Routing (src/mcp/routing.ts) is on unless FORGEQE_ROUTING=off, kept for comparing and for finding a routing fault. */
120
+ export const routingOn = (env: Record<string, string | undefined> = process.env) => !/^(off|0|false|no)$/i.test(env.FORGEQE_ROUTING || "")
121
+
118
122
  export async function openBrain(opts: BrainOptions): Promise<Brain> {
119
123
  const { config } = opts
120
124
  const cwd = realpathOr(opts.cwd)
121
125
  const context = (): ToolContext => ({ ...opts.context(), config, cwd })
122
- const tools = localToolServer({ tools: TOOLS, context, instructions: () => environmentNotes(context()), timeoutMs: TOOL_TIMEOUT_MS })
126
+ // Tools listed in one line and playbooks on demand keep every model call small; FORGEQE_ROUTING=off sends it all.
127
+ const router = routingOn() ? createRouter(TOOLS) : undefined
128
+ const tools = localToolServer({ tools: TOOLS, context, router, instructions: () => environmentNotes(context()), timeoutMs: TOOL_TIMEOUT_MS })
123
129
  const external = opts.external || externalMcp(config, Bun.which, process.env, { cwd })
124
130
  const notes = [environmentNotes(context()), ...externalNotes(external), ...(opts.notes || [])].filter(Boolean).join("\n")
125
131
  const readDirs = [...new Set([config.export.outDir, config.memory.dir, cwd].flatMap((d) => [path.resolve(d), realpathOr(d)]))]
@@ -147,7 +153,7 @@ export async function openBrain(opts: BrainOptions): Promise<Brain> {
147
153
  agent: {
148
154
  name: BRAIN_AGENT,
149
155
  description: "ForgeQE: browser runs, test cases, tests, and CI pipelines",
150
- prompt: `${brainPrompt()}\n\nThis session:\n${notes}`,
156
+ prompt: `${router ? router.parts.core : brainPrompt()}\n\nThis session:\n${notes}`,
151
157
  skills: "forgeqe",
152
158
  // Systems are read through MCP or query_api, never by scripts in the shell that handle tokens themselves.
153
159
  permission: { bash: agentBashPermission(privateEnvNames(config)) },