@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.
- package/HOW_TO_USE.md +40 -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 +54 -7
- package/evals/prompts/brain/route-api-scenarios.yaml +9 -0
- package/evals/prompts/brain/route-e2e.yaml +10 -0
- package/evals/prompts/brain/route-flaky.yaml +9 -0
- package/evals/prompts/brain/route-heal.yaml +9 -0
- package/evals/prompts/brain/route-regression-pack.yaml +9 -0
- package/evals/prompts/brain/route-review-update.yaml +10 -0
- package/evals/prompts/brain/route-run-cases.yaml +9 -0
- package/evals/prompts/brain/route-story-cases.yaml +12 -0
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/README.md +30 -0
- package/prompts/brain.md +34 -49
- 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/agents.ts +52 -0
- package/src/brain/index.ts +8 -2
- package/src/capabilities/registry.ts +48 -6
- 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 +9 -0
- package/src/cli/index.ts +71 -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/context.ts +75 -0
- package/src/engine/bridge.ts +47 -5
- package/src/mcp/captools.ts +19 -0
- package/src/mcp/casetools.ts +107 -11
- package/src/mcp/commands.ts +74 -20
- package/src/mcp/packtools.ts +187 -0
- package/src/mcp/routing.ts +145 -0
- package/src/mcp/server.ts +7 -3
- package/src/mcp/tools.ts +241 -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
|
@@ -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'}. "
|
|
@@ -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
|
-
|
|
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"]
|
package/src/brain/index.ts
CHANGED
|
@@ -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
|
-
|
|
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)) },
|