@capagents/forgeqe 1.1.1 → 1.1.3

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 (71) hide show
  1. package/HOW_TO_USE.md +25 -9
  2. package/README.md +30 -1
  3. package/browserstack.example.yaml +14 -0
  4. package/docs/security-data-flow.html +154 -86
  5. package/docs/user-guide.html +329 -135
  6. package/evals/prompts/brain/capability-question.yaml +12 -0
  7. package/evals/prompts/brain/failed-runs-cause.yaml +12 -0
  8. package/evals/prompts/brain/release-route.yaml +14 -0
  9. package/evals/prompts/brain/report-request.yaml +13 -0
  10. package/evals/prompts/brain/secret-request.yaml +9 -0
  11. package/evals/prompts/explore-charters/lockout.yaml +18 -0
  12. package/evals/prompts/explore-charters/transfer-limit.yaml +16 -0
  13. package/evals/prompts/refine/current-release.yaml +26 -0
  14. package/evals/prompts/refine/missing-item.yaml +10 -0
  15. package/evals/prompts/refine/question-stays-question.yaml +13 -0
  16. package/evals/prompts/refine/report-request.yaml +19 -0
  17. package/forgeqe.example.yaml +51 -0
  18. package/package.json +5 -2
  19. package/prompts/README.md +28 -7
  20. package/prompts/api-scenarios.md +56 -24
  21. package/prompts/api-tests.md +67 -51
  22. package/prompts/brain.md +277 -268
  23. package/prompts/browser-capture.md +8 -7
  24. package/prompts/browser-case-report.md +13 -12
  25. package/prompts/browser-checks.md +17 -18
  26. package/prompts/browser-efficiency.md +4 -1
  27. package/prompts/browser-error-recovery.md +5 -4
  28. package/prompts/browser-extra.md +1 -0
  29. package/prompts/browser-goal-verification.md +8 -7
  30. package/prompts/browser-skill-select.md +28 -1
  31. package/prompts/conventions.md +10 -0
  32. package/prompts/domain-rule.md +17 -14
  33. package/prompts/eval-judge.md +36 -13
  34. package/prompts/explore-charters.md +53 -22
  35. package/prompts/find-tests.md +45 -30
  36. package/prompts/heal.md +42 -32
  37. package/prompts/pr-review.md +44 -28
  38. package/prompts/refine.md +73 -40
  39. package/prompts/skill-rule.md +4 -1
  40. package/prompts/test-agent.md +61 -45
  41. package/prompts/test-reviewer.md +64 -37
  42. package/prompts/test-rules.md +59 -56
  43. package/py/forgeqe_engine/browser.py +111 -1
  44. package/py/forgeqe_engine/capture.py +8 -1
  45. package/py/forgeqe_engine/checks.py +20 -8
  46. package/py/forgeqe_engine/prompts.py +9 -0
  47. package/py/forgeqe_engine/report.py +15 -8
  48. package/py/forgeqe_engine/service.py +50 -27
  49. package/py/forgeqe_engine/skills.py +31 -7
  50. package/src/auth/signin.ts +30 -1
  51. package/src/bi.ts +914 -0
  52. package/src/cache.ts +92 -0
  53. package/src/capabilities/guide.ts +322 -140
  54. package/src/capabilities/registry.ts +3 -3
  55. package/src/childEnv.ts +4 -0
  56. package/src/cli/help.ts +18 -4
  57. package/src/cli/index.ts +149 -15
  58. package/src/config.ts +272 -0
  59. package/src/engine/bridge.ts +32 -2
  60. package/src/evals/prompts.ts +288 -0
  61. package/src/heal/loop.ts +3 -1
  62. package/src/init.ts +18 -0
  63. package/src/mcp/server.ts +8 -0
  64. package/src/mcp/tools.ts +30 -4
  65. package/src/monitor/run.ts +3 -1
  66. package/src/prompts.ts +6 -3
  67. package/src/promptstyle.ts +159 -0
  68. package/src/run.ts +4 -1
  69. package/src/ste.ts +8 -2
  70. package/src/suite/run.ts +3 -1
  71. package/testmu.example.yaml +14 -0
@@ -0,0 +1,288 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import YAML from "yaml"
5
+
6
+ import type { AppConfig } from "../config.ts"
7
+ import { configuredSystems } from "../capabilities/status.ts"
8
+ import { steScore } from "../ste.ts"
9
+ import type { CodeEvent } from "../types.ts"
10
+
11
+ /**
12
+ * Evaluation-driven prompting: each prompt that can run on its own has cases (evals/prompts/<prompt>/*.yaml in
13
+ * ForgeQE, and .forgeqe/evals/prompts/<prompt>/ for a project's own), each an input and checks that are decided by
14
+ * code, not by a model: a field matches or is empty, the agent used or did not use a tool, the text keeps ASD-STE100.
15
+ * `forgeqe prompts eval` runs them, several times when asked, and compares the pass rate with the run before, so a
16
+ * change to a prompt is measured, not guessed.
17
+ */
18
+
19
+ export const BUILTIN_CASES = path.resolve(import.meta.dir, "..", "..", "evals", "prompts")
20
+
21
+ /** One check. Exactly one of the test keys is set besides `id` (and `field` for the field tests). */
22
+ export type Check = {
23
+ id: string
24
+ field?: string
25
+ matches?: string
26
+ not_matches?: string
27
+ empty?: boolean
28
+ min_items?: number
29
+ max_items?: number
30
+ /** Least share of sentences that keep ASD-STE100, of `field` (default: the output text). */
31
+ ste_min?: number
32
+ /** At least one tool call matches one of these names (`*` matches any text). */
33
+ tools_any?: string[]
34
+ /** No tool call matches any of these. */
35
+ tools_none?: string[]
36
+ /** The first call that matches the first list comes before any call that matches the second. */
37
+ tools_before?: [string[], string[]]
38
+ }
39
+
40
+ export type PromptCase = {
41
+ id: string
42
+ title: string
43
+ prompt: string
44
+ input: string
45
+ /** Systems the case reads: it is skipped when one is not set up. */
46
+ needs: string[]
47
+ /** brain: the ForgeQE tools that may run; others answer that they are not available (default: those that only read). */
48
+ allow?: string[]
49
+ checks: Check[]
50
+ file: string
51
+ }
52
+
53
+ export type Observation = { fields: Record<string, unknown>; text: string; tools: string[]; tokens: number; ms: number }
54
+ export type CheckResult = { id: string; pass: boolean; detail: string }
55
+ export type RunResult = { pass: boolean; checks: CheckResult[]; tokens: number; ms: number; error?: string; /** The start of what came out, for a run that failed. */ seen?: string }
56
+ export type CaseResult = { prompt: string; case: string; title: string; runs: RunResult[]; skipped?: string }
57
+
58
+ /** The ForgeQE tools ForgeQE's agent may call in an evaluation: each only reads. */
59
+ export const READ_ONLY_TOOLS = ["capabilities", "find_test_cases", "check_test_cases", "read_confluence", "run_details", "query_api", "list_sprints", "page_memory", "flaky_tests", "quality_report", "quality_trends", "forgeqe_setup"]
60
+
61
+ const TESTS = ["matches", "not_matches", "empty", "min_items", "max_items", "ste_min", "tools_any", "tools_none", "tools_before"] as const
62
+
63
+ /** Every case in a folder of `<prompt>/<case>.yaml`, checked so a broken case fails before any model is called. */
64
+ export function loadPromptCases(dirs: string[], only?: { prompt?: string; case?: string }): PromptCase[] {
65
+ const out: PromptCase[] = []
66
+ for (const dir of dirs) {
67
+ if (!existsSync(dir)) continue
68
+ for (const prompt of readdirSync(dir).sort()) {
69
+ if (only?.prompt && prompt !== only.prompt) continue
70
+ const folder = path.join(dir, prompt)
71
+ let files: string[] = []
72
+ try {
73
+ files = readdirSync(folder).filter((f) => /\.ya?ml$/.test(f)).sort()
74
+ } catch {
75
+ continue
76
+ }
77
+ for (const f of files) {
78
+ const file = path.join(folder, f)
79
+ const c = parseCase(YAML.parse(readFileSync(file, "utf8")), prompt, file)
80
+ if (only?.case && c.id !== only.case) continue
81
+ // A project's case replaces ForgeQE's case of the same id.
82
+ const at = out.findIndex((x) => x.prompt === c.prompt && x.id === c.id)
83
+ if (at >= 0) out.splice(at, 1)
84
+ out.push(c)
85
+ }
86
+ }
87
+ }
88
+ return out
89
+ }
90
+
91
+ export function parseCase(raw: any, prompt: string, file: string): PromptCase {
92
+ const where = `${path.basename(path.dirname(file))}/${path.basename(file)}`
93
+ if (!raw?.id || !raw.input) throw new Error(`${where}: id and input are required.`)
94
+ if (!Array.isArray(raw.checks) || !raw.checks.length) throw new Error(`${where}: checks needs at least one check.`)
95
+ const checks: Check[] = raw.checks.map((c: any, i: number) => {
96
+ if (!c?.id) throw new Error(`${where}: check ${i + 1} needs an id.`)
97
+ const tests = TESTS.filter((t) => c[t] !== undefined)
98
+ if (tests.length !== 1) throw new Error(`${where}: check ${c.id} needs exactly one of ${TESTS.join(", ")}.`)
99
+ if (["matches", "not_matches", "empty", "min_items", "max_items"].includes(tests[0]!) && !c.field) throw new Error(`${where}: check ${c.id} needs field.`)
100
+ for (const k of ["matches", "not_matches"]) if (c[k] !== undefined) new RegExp(String(c[k]), "i")
101
+ return c as Check
102
+ })
103
+ const ids = checks.map((c) => c.id)
104
+ if (new Set(ids).size !== ids.length) throw new Error(`${where}: check ids must be unique.`)
105
+ return {
106
+ id: String(raw.id),
107
+ title: String(raw.title || raw.id),
108
+ prompt: String(raw.prompt || prompt),
109
+ input: String(raw.input),
110
+ needs: (raw.needs || []).map(String),
111
+ ...(raw.allow ? { allow: raw.allow.map(String) } : {}),
112
+ checks,
113
+ file,
114
+ }
115
+ }
116
+
117
+ const glob = (pattern: string) => new RegExp(`^${pattern.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i")
118
+ const matchesAny = (tool: string, patterns: string[]) => patterns.some((p) => glob(p).test(tool))
119
+ const asText = (v: unknown): string => (v === undefined || v === null ? "" : typeof v === "string" ? v : Array.isArray(v) ? v.map(asText).join("\n") : JSON.stringify(v))
120
+
121
+ /** Each check decided against what one run did. */
122
+ export function judge(checks: Check[], o: Observation): CheckResult[] {
123
+ return checks.map((c): CheckResult => {
124
+ const value = c.field ? o.fields[c.field] : o.text
125
+ const text = asText(value)
126
+ const items = Array.isArray(value) ? value.length : text.trim() ? 1 : 0
127
+ if (c.matches !== undefined) {
128
+ const pass = new RegExp(c.matches, "i").test(text)
129
+ return { id: c.id, pass, detail: pass ? `${c.field} matches /${c.matches}/` : `${c.field} does not match /${c.matches}/: ${clip(text)}` }
130
+ }
131
+ if (c.not_matches !== undefined) {
132
+ const m = new RegExp(c.not_matches, "i").exec(text)
133
+ return { id: c.id, pass: !m, detail: m ? `${c.field} has "${m[0]}": ${clip(text)}` : `${c.field} does not match /${c.not_matches}/` }
134
+ }
135
+ if (c.empty !== undefined) {
136
+ const pass = (items === 0) === c.empty
137
+ return { id: c.id, pass, detail: pass ? `${c.field} is ${c.empty ? "empty" : "not empty"}` : `${c.field} is ${c.empty ? `not empty: ${clip(text)}` : "empty"}` }
138
+ }
139
+ if (c.min_items !== undefined) return { id: c.id, pass: items >= c.min_items, detail: `${c.field} has ${items} (at least ${c.min_items})` }
140
+ if (c.max_items !== undefined) return { id: c.id, pass: items <= c.max_items, detail: `${c.field} has ${items} (at most ${c.max_items})` }
141
+ if (c.ste_min !== undefined) {
142
+ const score = steScore(text).score
143
+ return { id: c.id, pass: score >= c.ste_min, detail: `ASD-STE100 ${Math.round(score * 100)}% (at least ${Math.round(c.ste_min * 100)}%)` }
144
+ }
145
+ if (c.tools_any) {
146
+ const hit = o.tools.find((t) => matchesAny(t, c.tools_any!))
147
+ return { id: c.id, pass: Boolean(hit), detail: hit ? `used ${hit}` : `used none of ${c.tools_any.join(", ")} (used: ${clip(o.tools.join(", ") || "no tools")})` }
148
+ }
149
+ if (c.tools_none) {
150
+ const hit = o.tools.filter((t) => matchesAny(t, c.tools_none!))
151
+ return { id: c.id, pass: !hit.length, detail: hit.length ? `used ${[...new Set(hit)].join(", ")}` : `used none of ${c.tools_none.join(", ")}` }
152
+ }
153
+ const [first, then] = c.tools_before!
154
+ const a = o.tools.findIndex((t) => matchesAny(t, first))
155
+ const b = o.tools.findIndex((t) => matchesAny(t, then))
156
+ const pass = a >= 0 && (b < 0 || a < b)
157
+ return { id: c.id, pass, detail: a < 0 ? `used none of ${first.join(", ")}` : b >= 0 && b < a ? `used ${o.tools[b]} before ${o.tools[a]}` : `used ${o.tools[a]} first` }
158
+ })
159
+ }
160
+
161
+ const clip = (s: string, n = 120) => {
162
+ const t = s.replace(/\s+/g, " ").trim()
163
+ return t.length <= n ? t : `${t.slice(0, n - 1)}…`
164
+ }
165
+
166
+ /** Why a case cannot run with this config, or null. */
167
+ export function skipReason(c: PromptCase, config: AppConfig): string | null {
168
+ if (config.llm.provider === "none" || config.llm.provider === "mock") return "no live model"
169
+ const systems = configuredSystems(config)
170
+ const missing = c.needs.filter((n) => !systems.has(n as never))
171
+ return missing.length ? `needs ${missing.join(", ")}` : null
172
+ }
173
+
174
+ export type Runner = (c: PromptCase, config: AppConfig, cwd: string) => Promise<Observation>
175
+
176
+ /** Tokens a session used, from its last usage event (the runtime reports running totals). */
177
+ function usageMeter() {
178
+ let tokens = 0
179
+ return {
180
+ onEvent: (e: CodeEvent) => {
181
+ if (e.kind === "usage") tokens = e.usage.input + e.usage.output
182
+ },
183
+ get tokens() {
184
+ return tokens
185
+ },
186
+ }
187
+ }
188
+
189
+ /** How each prompt that can run on its own is run for a case. */
190
+ export const RUNNERS: Record<string, Runner> = {
191
+ async refine(c, config, cwd) {
192
+ const { refine } = await import("../refine.ts")
193
+ const meter = usageMeter()
194
+ const started = Date.now()
195
+ const r = await refine({ config, cwd, text: c.input, recent: [], onEvent: meter.onEvent })
196
+ const fields: Record<string, unknown> = { ...(r.spec || {}), refined: r.refined, missing: r.missing, assumed: r.assumed, grounded: r.grounded.map((g) => g.fact) }
197
+ return { fields, text: r.refined, tools: [], tokens: meter.tokens, ms: Date.now() - started }
198
+ },
199
+
200
+ async brain(c, config, cwd) {
201
+ const { openBrain } = await import("../brain/index.ts")
202
+ const tools: string[] = []
203
+ let tokens = 0
204
+ const allow = new Set(c.allow || READ_ONLY_TOOLS)
205
+ const started = Date.now()
206
+ const brain = await openBrain({
207
+ config,
208
+ cwd,
209
+ context: () => ({ session: "shell" as const, observe: (tool: string) => tools.push(`forgeqe_${tool}`), allow: (tool: string) => allow.has(tool) }),
210
+ onEvent: (e) => {
211
+ if (e.kind === "usage") tokens = e.usage.input + e.usage.output
212
+ // ForgeQE's own tools are hidden from these events; observe records them.
213
+ if (e.kind === "tool" && e.tool && !e.tool.startsWith("forgeqe_")) tools.push(e.tool)
214
+ },
215
+ })
216
+ try {
217
+ const a = await brain.ask(c.input)
218
+ return { fields: { reply: a.reply, outcome: a.outcome, next: (a.next || []).map((n) => n.label) }, text: a.reply, tools, tokens, ms: Date.now() - started }
219
+ } finally {
220
+ await brain.close()
221
+ }
222
+ },
223
+
224
+ async "explore-charters"(c, config, cwd) {
225
+ const { planCharters } = await import("../explore/charters.ts")
226
+ const meter = usageMeter()
227
+ const started = Date.now()
228
+ const [title, ...rest] = c.input.split("\n")
229
+ const acceptance = rest.join("\n")
230
+ const story = { id: "story", system: "text" as const, title: title!.trim(), description: "", acceptance, parents: [], scripted: [], passages: [], errors: [] }
231
+ const r = await planCharters({ config, story, count: 5, roles: [], cwd, onEvent: meter.onEvent })
232
+ const fields = { charters: r.charters, titles: r.charters.map((x) => x.title), categories: r.charters.map((x) => x.category), steps: r.charters.flatMap((x) => x.steps.map((s) => `${s.action} → ${s.expected}`)) }
233
+ // Each mission is a paragraph of its own, ended as a sentence, so writing checks score each one.
234
+ const text = r.charters.map((x) => (/[.!?]$/.test(x.mission.trim()) ? x.mission.trim() : `${x.mission.trim()}.`)).join("\n\n")
235
+ return { fields, text, tools: [], tokens: meter.tokens, ms: Date.now() - started }
236
+ },
237
+ }
238
+
239
+ export async function runCase(c: PromptCase, config: AppConfig, cwd: string, runs: number): Promise<CaseResult> {
240
+ const base = { prompt: c.prompt, case: c.id, title: c.title }
241
+ const why = skipReason(c, config)
242
+ if (why) return { ...base, runs: [], skipped: why }
243
+ const runner = RUNNERS[c.prompt]
244
+ if (!runner) return { ...base, runs: [], skipped: `no runner for ${c.prompt} yet` }
245
+ const out: RunResult[] = []
246
+ for (let i = 0; i < runs; i++) {
247
+ const started = Date.now()
248
+ try {
249
+ const o = await runner(c, config, cwd)
250
+ const checks = judge(c.checks, o)
251
+ const pass = checks.every((x) => x.pass)
252
+ out.push({ pass, checks, tokens: o.tokens, ms: o.ms, ...(pass ? {} : { seen: clip(o.text, 300) }) })
253
+ } catch (err) {
254
+ out.push({ pass: false, checks: [], tokens: 0, ms: Date.now() - started, error: (err as Error).message })
255
+ }
256
+ }
257
+ return { ...base, runs: out }
258
+ }
259
+
260
+ /** Where results are kept, one file per `forgeqe prompts eval`. */
261
+ export const resultsDir = (config: AppConfig) => path.join(config.export.outDir, "evals", "prompts")
262
+
263
+ export function saveResults(config: AppConfig, results: CaseResult[], at = new Date()): string {
264
+ const dir = resultsDir(config)
265
+ mkdirSync(dir, { recursive: true })
266
+ const file = path.join(dir, `${at.toISOString().replace(/[:.]/g, "-")}.json`)
267
+ writeFileSync(file, JSON.stringify({ at: at.toISOString(), results }, null, 2))
268
+ return file
269
+ }
270
+
271
+ /** The pass rate of each case in the latest earlier results that ran it: "prompt/case" → [passed, runs]. */
272
+ export function previousRates(config: AppConfig, except?: string): Map<string, [number, number]> {
273
+ const dir = resultsDir(config)
274
+ const out = new Map<string, [number, number]>()
275
+ if (!existsSync(dir)) return out
276
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json")).sort().reverse()
277
+ for (const f of files) {
278
+ if (except && path.join(dir, f) === except) continue
279
+ try {
280
+ const { results } = JSON.parse(readFileSync(path.join(dir, f), "utf8")) as { results: CaseResult[] }
281
+ for (const r of results) {
282
+ const key = `${r.prompt}/${r.case}`
283
+ if (!out.has(key) && r.runs.length) out.set(key, [r.runs.filter((x) => x.pass).length, r.runs.length])
284
+ }
285
+ } catch {}
286
+ }
287
+ return out
288
+ }
package/src/heal/loop.ts CHANGED
@@ -7,7 +7,7 @@ import { addUsage, costOf, meterSince, meterSnapshot, type Cost } from "../usage
7
7
  import { openSession, type OpencodeSession } from "../code/runtime.ts"
8
8
  import { runTestCommand, type TestRun } from "../code/testrun.ts"
9
9
  import { changedFiles, ownPaths, protectPatterns, restoreRemoved, snapshot } from "../code/worktree.ts"
10
- import type { AppConfig } from "../config.ts"
10
+ import type { AppConfig, BrowserProvider } from "../config.ts"
11
11
  import { MCP_NAME, TOOL_TIMEOUT_MS, localToolServer } from "../mcp/server.ts"
12
12
  import { TOOLS, environmentNotes, type ToolContext, type ToolEvent } from "../mcp/tools.ts"
13
13
  import { runDirectory } from "../run.ts"
@@ -79,6 +79,7 @@ export type HealOptions = {
79
79
  /** The repo's test command; default code.test_command, else the agent finds it. */
80
80
  command?: string
81
81
  outDir?: string
82
+ browserProvider?: BrowserProvider
82
83
  headed?: boolean
83
84
  maxSteps?: number
84
85
  url?: string
@@ -149,6 +150,7 @@ export async function runHeal(opts: HealOptions): Promise<HealResult> {
149
150
  config: runsConfig,
150
151
  cwd: repo,
151
152
  headed: opts.headed,
153
+ browserProvider: opts.browserProvider,
152
154
  maxSteps: opts.maxSteps,
153
155
  url: opts.url,
154
156
  signal: opts.signal,
package/src/init.ts CHANGED
@@ -64,6 +64,24 @@ export function initWorkspace(
64
64
  // Prompts, skills, and product knowledge templates, to read and change: .forgeqe/ in a project, ~/.forgeqe for the user.
65
65
  const root = home ? forgeqeHome() : projectDir(directory)
66
66
  const shown = (p: string) => (home ? p : path.relative(directory, p) || p)
67
+ for (const [srcName, destName] of [
68
+ ["browserstack.example.yaml", "browserstack.yaml"],
69
+ ["testmu.example.yaml", "testmu.yaml"],
70
+ ] as const) {
71
+ const src = path.join(ROOT, srcName)
72
+ const dest = path.join(root, destName)
73
+ if (!existsSync(src)) {
74
+ notes.push(`Template missing: ${src}`)
75
+ continue
76
+ }
77
+ if (existsSync(dest) && !opts.force) {
78
+ notes.push(`Kept existing ${shown(dest)}`)
79
+ continue
80
+ }
81
+ mkdirSync(path.dirname(dest), { recursive: true })
82
+ writeFileSync(dest, readFileSync(src, "utf8"), "utf8")
83
+ created.push(dest)
84
+ }
67
85
  for (const { what, src, dest, keys } of [
68
86
  { what: "prompts", src: PROMPTS_DIR, dest: path.join(root, "prompts"), keys: entries(PROMPTS_DIR, (e) => e.endsWith(".md")) },
69
87
  { what: "skills", src: PREBUILT_DIR, dest: path.join(root, "skills"), keys: entries(PREBUILT_DIR, (e) => existsSync(path.join(PREBUILT_DIR, e, "SKILL.md"))) },
package/src/mcp/server.ts CHANGED
@@ -42,6 +42,14 @@ export function createMcpServer(opts: McpServerOptions) {
42
42
  const timer = token !== undefined ? setInterval(() => send({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken: token, progress: ++tick, message: `${tool.name} running` } }), opts.progressMs || PROGRESS_EVERY_MS) : null
43
43
  const started = Date.now()
44
44
  logEvent("tool.call", { tool: tool.name, arguments: params.arguments || {} })
45
+ base.observe?.(tool.name, params.arguments || {})
46
+ if (base.allow && !base.allow(tool.name)) {
47
+ running.delete(id)
48
+ if (timer) clearInterval(timer)
49
+ base.signal?.removeEventListener("abort", abort)
50
+ outer?.removeEventListener("abort", abort)
51
+ return { content: [{ type: "text", text: `${tool.name} is not available in this evaluation. Say what you would do with it, and stop.` }], isError: true }
52
+ }
45
53
  const finish = (result: Json) => {
46
54
  logEvent("tool.result", { tool: tool.name, ms: Date.now() - started, error: Boolean(result.isError), text: result.content?.[0]?.text })
47
55
  return result
package/src/mcp/tools.ts CHANGED
@@ -15,7 +15,7 @@ import { buildHandoff } from "../code/handoff.ts"
15
15
  import { latestRunDir, runCodeAgent } from "../code/index.ts"
16
16
  import type { OpencodeSession } from "../code/runtime.ts"
17
17
  import { runTestCommand } from "../code/testrun.ts"
18
- import type { AppConfig } from "../config.ts"
18
+ import { browserProvider, type AppConfig } from "../config.ts"
19
19
  import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow } from "../memory.ts"
20
20
  import { executeRun, runDirectory } from "../run.ts"
21
21
  import { counts } from "../suite/report.ts"
@@ -123,6 +123,7 @@ export type ToolContext = {
123
123
  /** The repository or folder the agent works in. */
124
124
  cwd: string
125
125
  headed?: boolean
126
+ browserProvider?: AppConfig["browser"]["provider"]
126
127
  maxSteps?: number
127
128
  replay?: ReplayMode
128
129
  memory?: boolean
@@ -130,6 +131,10 @@ export type ToolContext = {
130
131
  url?: string
131
132
  signal?: AbortSignal
132
133
  emit?: (e: ToolEvent) => void
134
+ /** Sees every call before it runs, with its arguments: evaluations record which tools the agent chose. */
135
+ observe?: (tool: string, args: Record<string, unknown>) => void
136
+ /** Whether a tool may run; a refused call answers that it is not available here (evaluations refuse tools that act). */
137
+ allow?: (tool: string) => boolean
133
138
  /** How long this process lives: the shell, one `forgeqe run`, or `forgeqe mcp`. Background monitors need shell or server. */
134
139
  session?: "shell" | "once" | "server"
135
140
  /** Passed browser runs become tests in cwd (the shell and `forgeqe run` without --no-code). */
@@ -252,7 +257,7 @@ export const TOOLS: McpTool[] = [
252
257
  {
253
258
  name: "browser_run",
254
259
  description:
255
- "Carry out a goal in a real browser with ForgeQE's browser agent and verify the outcome. The agent picks the site from the goal unless start_url is given. Returns pass/fail, the agent's summary, the steps, and the run folder (run.json, workflow.yaml with ranked locators, trace.md, steps and screenshots, report.html). Takes minutes.",
260
+ "Carry out a goal in a real browser with ForgeQE's browser agent and verify the outcome. The browser comes from forgeqe.yaml: local Chrome or Chromium by default, or BrowserStack or TestMu AI/LambdaTest when browser.provider selects one. The agent picks the site from the goal unless start_url is given. Returns pass/fail, the agent's summary, the steps, and the run folder (run.json, workflow.yaml with ranked locators, trace.md, steps and screenshots, report.html). Takes minutes.",
256
261
  inputSchema: {
257
262
  type: "object",
258
263
  properties: {
@@ -262,6 +267,12 @@ export const TOOLS: McpTool[] = [
262
267
  "The final instructions for the browser agent: what to do and what to check, in plain language, with every value it needs. Only the decided task; never your own questions, alternatives, or reasoning about it.",
263
268
  },
264
269
  start_url: { type: "string", description: "Page to open first, only when the user named one" },
270
+ browser_provider: {
271
+ type: "string",
272
+ enum: ["local", "browserstack", "testmu"],
273
+ description:
274
+ "Override the browser provider for this run when the user asked for one specifically. local uses the machine's own browser; browserstack and testmu use their configured YAML or browser.remote settings.",
275
+ },
265
276
  resume_from: {
266
277
  type: "string",
267
278
  description:
@@ -283,6 +294,7 @@ export const TOOLS: McpTool[] = [
283
294
  const from = args.resume_from ? String(args.resume_from) : ""
284
295
  const resumeFrom = from ? (existsSync(path.resolve(ctx.cwd, from)) ? path.resolve(ctx.cwd, from) : from) : undefined
285
296
  const url = args.start_url ? String(args.start_url) : ctx.url
297
+ const selectedBrowser = args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
286
298
  const runDir = runDirectory(ctx.config.export.outDir)
287
299
  const emit = ctx.emit || (() => {})
288
300
  const t0 = Date.now()
@@ -291,6 +303,7 @@ export const TOOLS: McpTool[] = [
291
303
  const { result, files } = await (ctx.execute || executeRun)({
292
304
  config: ctx.config,
293
305
  goal,
306
+ browserProvider: selectedBrowser,
294
307
  url,
295
308
  headed: ctx.headed,
296
309
  maxSteps: ctx.maxSteps,
@@ -406,13 +419,19 @@ export const TOOLS: McpTool[] = [
406
419
  {
407
420
  name: "run_test_cases",
408
421
  description:
409
- "Run test cases in real browsers: each case is one browser goal whose steps are done in order and whose expected results are checked, with a verdict for every step. Writes report.html (steps, screenshots, timings) and junit.xml, posts results back to Azure DevOps, Jira, Xray, TestRail, Zephyr Scale, or qTest unless write_back is false, and files or updates bugs for failures when bugs are set up. In a session that writes tests, each passed case is handed to ForgeQE's coding agent as soon as its browser run ends, and its test is written and run before the next case starts. Returns each case's result, step verdicts, run folder, and test (file, command, status, or why none was written). Takes minutes.",
422
+ "Run test cases in real browsers: each case is one browser goal whose steps are done in order and whose expected results are checked, with a verdict for every step. The browser comes from forgeqe.yaml: local Chrome or Chromium by default, or BrowserStack or TestMu AI/LambdaTest when browser.provider selects one. Writes report.html (steps, screenshots, timings) and junit.xml, posts results back to Azure DevOps, Jira, Xray, TestRail, Zephyr Scale, or qTest unless write_back is false, and files or updates bugs for failures when bugs are set up. In a session that writes tests, each passed case is handed to ForgeQE's coding agent as soon as its browser run ends, and its test is written and run before the next case starts. Returns each case's result, step verdicts, run folder, and test (file, command, status, or why none was written). Takes minutes.",
410
423
  inputSchema: {
411
424
  type: "object",
412
425
  properties: {
413
426
  sources: SOURCES_SCHEMA,
414
427
  parallel: { type: "integer", minimum: 1, maximum: 8, description: "Browsers at once (default from config)" },
415
428
  write_back: { type: "boolean", description: "Post results to the source system (default from config)" },
429
+ browser_provider: {
430
+ type: "string",
431
+ enum: ["local", "browserstack", "testmu"],
432
+ description:
433
+ "Override the browser provider for every case in this suite when the user asked for one specifically.",
434
+ },
416
435
  skills: SKILLS_SCHEMA,
417
436
  },
418
437
  required: ["sources"],
@@ -425,6 +444,7 @@ export const TOOLS: McpTool[] = [
425
444
  if (!cases.length) return { ran: false, reason: "No case can run as written (suite.case_check: block).", check: checkResult(checks) }
426
445
  if (!cases.length) throw new Error("No test cases found.")
427
446
  const parallel = Math.min(Number(args.parallel) || ctx.config.suite.parallel, cases.length)
447
+ const selectedBrowser = args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
428
448
  const emit = ctx.emit || (() => {})
429
449
  emit({ type: "suite-start", cases, parallel })
430
450
  // In a session that writes tests, each passed case goes to the coding agent right after its own browser run,
@@ -451,6 +471,7 @@ export const TOOLS: McpTool[] = [
451
471
  config: ctx.writeTests ? { ...ctx.config, suite: { ...ctx.config.suite, code: true } } : ctx.config,
452
472
  cases,
453
473
  ...(ctx.writeTests ? { repo: ctx.cwd, coder } : {}),
474
+ browserProvider: selectedBrowser,
454
475
  parallel,
455
476
  writeBack: typeof args.write_back === "boolean" ? args.write_back : undefined,
456
477
  headed: ctx.headed,
@@ -761,7 +782,7 @@ function knowledgeNotes(cfg: AppConfig): string {
761
782
  : `No product knowledge yet: the team can describe the product, roles, rules, and test data in ${cfg.knowledge.dir} (forgeqe init adds templates).`
762
783
  }
763
784
 
764
- export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url">): string {
785
+ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url" | "browserProvider">): string {
765
786
  const { config: cfg } = ctx
766
787
  const s = cfg.sources
767
788
  const systems = [
@@ -798,13 +819,17 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
798
819
  const monitor = m.checks.length || channels.length
799
820
  ? `Monitoring (monitor: in forgeqe.yaml): ${m.checks.length ? `checks ${m.checks.join(" ")}` : "no checks yet"}, every ${Math.round(m.everyMs / 60_000)} min, alerts to ${channels.length ? channels.join(" and ") : "nobody yet"}.`
800
821
  : ""
822
+ const browser = cfg.browser || {}
801
823
  const browserSkills = skillsFor(loadSkills(cfg.skills, ctx.cwd), "browser")
824
+ const browserProvider = browser.provider || "local"
825
+ const remoteProviders = (["browserstack", "testmu"] as const).filter((name) => browser.remotes?.[name])
802
826
  const skills = browserSkills.length
803
827
  ? [`Browser skills (pass the ones a goal needs as skills to browser_run or run_test_cases):`, ...catalogLines(browserSkills)].join("\n")
804
828
  : ""
805
829
  return [
806
830
  `Working directory: ${ctx.cwd}`,
807
831
  `Browser runs are saved under ${cfg.export.outDir}; page memory is in ${cfg.memory.dir}.`,
832
+ `Browser provider default: ${browserProvider}.${remoteProviders.length ? ` Also configured: ${remoteProviders.join(", ")}. Pass browser_provider to browser_run or run_test_cases when the user asks for one of them.` : ""}`,
808
833
  `Test case systems configured: ${systems.length ? systems.join("; ") : "none (files and text still work)"}.`,
809
834
  s.confluence ? `Confluence: ${s.confluence.url}${s.confluence.space ? `, space ${s.confluence.space}` : ""} (read_confluence).` : "",
810
835
  `Sprints (list_sprints, find_tests sprint): ${[s.ado ? "Azure DevOps iterations" : "", s.jira ? "Jira scrum boards" : ""].filter(Boolean).join(" and ") || "none configured; ask the user for dates"}.`,
@@ -827,6 +852,7 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
827
852
  `Browser model profile: ${cfg.activeProfile} (${cfg.llm.provider}/${cfg.llm.deployment || cfg.llm.model}).`,
828
853
  keys.length ? `Set in ForgeQE's environment (names only, usable with set_ci_secret): ${keys.join(", ")}.` : "",
829
854
  ctx.url ? `The user pinned this start URL for browser runs: ${ctx.url}` : "",
855
+ ctx.browserProvider ? `The user pinned this browser provider for runs in this session: ${ctx.browserProvider}.` : "",
830
856
  skills,
831
857
  domainNotes(cfg),
832
858
  knowledgeNotes(cfg),
@@ -3,7 +3,7 @@ import path from "node:path"
3
3
 
4
4
  import type { Card } from "../card.ts"
5
5
  import { loadCases, type TestCase } from "../cases/index.ts"
6
- import type { AppConfig } from "../config.ts"
6
+ import type { AppConfig, BrowserProvider } from "../config.ts"
7
7
  import { counts, suiteUsage } from "../suite/report.ts"
8
8
  import { runSuite, type SuiteEvent, type SuiteOptions, type SuiteResult } from "../suite/run.ts"
9
9
  import { formatDuration, formatTokens } from "../tui/theme.ts"
@@ -28,6 +28,7 @@ export type MonitorOptions = {
28
28
  config: AppConfig
29
29
  /** Test case sources; default monitor.checks. */
30
30
  checks: string[]
31
+ browserProvider?: BrowserProvider
31
32
  headed?: boolean
32
33
  url?: string
33
34
  /** Browser skills for every check (empty: none); unset: the engine picks once per check for the monitor's lifetime. */
@@ -67,6 +68,7 @@ export async function monitorRound(opts: MonitorOptions): Promise<RoundResult> {
67
68
  suite = await runSuite({
68
69
  config: { ...config, suite: { ...config.suite, outDir: m.outDir, code: false } },
69
70
  cases,
71
+ browserProvider: opts.browserProvider,
70
72
  parallel: Math.min(m.parallel, cases.length),
71
73
  writeBack: m.writeBack,
72
74
  replay: m.replay,
package/src/prompts.ts CHANGED
@@ -35,6 +35,8 @@ export type PromptInfo = {
35
35
  name: string
36
36
  description: string
37
37
  usedBy: string
38
+ /** What kind of prompt it is, which decides the standard it is checked against (src/promptstyle.ts). */
39
+ kind: string
38
40
  source: PromptSource | string
39
41
  file: string
40
42
  /** An edited copy whose built-in prompt changed since the copy was made. */
@@ -103,7 +105,7 @@ export function listPrompts(): PromptInfo[] {
103
105
  const name = f.replace(/\.md$/, "")
104
106
  const { meta } = parse(readFileSync(path.join(PROMPTS_DIR, f), "utf8"))
105
107
  const where = promptFile(name)
106
- return { name, description: meta.description || "", usedBy: meta.used_by || "", source: where.source, file: where.file, outdated: where.outdated }
108
+ return { name, description: meta.description || "", usedBy: meta.used_by || "", kind: meta.kind || "", source: where.source, file: where.file, outdated: where.outdated }
107
109
  })
108
110
  }
109
111
 
@@ -114,12 +116,13 @@ export function promptWarnings(): string[] {
114
116
  .map((p) => `Your prompt ${p.name} (${p.file}) is based on an older version of ForgeQE's; forgeqe prompts diff ${p.name} shows what changed.`)
115
117
  }
116
118
 
117
- /** The browser engine's prompts (browser-*.md), resolved, for the engine to use instead of its own copies. */
119
+ /** The browser engine's prompts (browser-*.md and conventions.md), resolved, for the engine to use instead of its own copies. */
118
120
  export function enginePrompts(): Record<string, string> {
119
121
  const out: Record<string, string> = {}
120
122
  for (const f of readdirSync(PROMPTS_DIR)) {
121
123
  const name = f.replace(/\.md$/, "")
122
- if (!f.endsWith(".md") || !name.startsWith("browser-") || name === "browser-extra") continue
124
+ // The browser agent's prompts, and the conventions that state how their rules read.
125
+ if (!f.endsWith(".md") || !(name.startsWith("browser-") || name === "conventions") || name === "browser-extra") continue
123
126
  out[name] = prompt(name)
124
127
  }
125
128
  return out