@capagents/forgeqe 1.1.0 → 1.1.2

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 (64) hide show
  1. package/HOW_TO_USE.md +30 -13
  2. package/README.md +27 -1
  3. package/docs/user-guide.html +33 -13
  4. package/evals/prompts/brain/capability-question.yaml +12 -0
  5. package/evals/prompts/brain/failed-runs-cause.yaml +12 -0
  6. package/evals/prompts/brain/release-route.yaml +14 -0
  7. package/evals/prompts/brain/report-request.yaml +13 -0
  8. package/evals/prompts/brain/secret-request.yaml +9 -0
  9. package/evals/prompts/explore-charters/lockout.yaml +18 -0
  10. package/evals/prompts/explore-charters/transfer-limit.yaml +16 -0
  11. package/evals/prompts/refine/current-release.yaml +26 -0
  12. package/evals/prompts/refine/missing-item.yaml +10 -0
  13. package/evals/prompts/refine/question-stays-question.yaml +13 -0
  14. package/evals/prompts/refine/report-request.yaml +19 -0
  15. package/forgeqe.example.yaml +39 -0
  16. package/package.json +1 -1
  17. package/prompts/README.md +28 -7
  18. package/prompts/api-scenarios.md +56 -24
  19. package/prompts/api-tests.md +67 -51
  20. package/prompts/brain.md +283 -266
  21. package/prompts/browser-capture.md +8 -7
  22. package/prompts/browser-case-report.md +13 -12
  23. package/prompts/browser-checks.md +17 -18
  24. package/prompts/browser-efficiency.md +4 -1
  25. package/prompts/browser-error-recovery.md +5 -4
  26. package/prompts/browser-extra.md +1 -0
  27. package/prompts/browser-goal-verification.md +8 -7
  28. package/prompts/browser-skill-select.md +28 -1
  29. package/prompts/conventions.md +10 -0
  30. package/prompts/domain-rule.md +17 -14
  31. package/prompts/eval-judge.md +36 -13
  32. package/prompts/explore-charters.md +53 -22
  33. package/prompts/find-tests.md +45 -30
  34. package/prompts/heal.md +42 -32
  35. package/prompts/pr-review.md +44 -28
  36. package/prompts/refine.md +73 -36
  37. package/prompts/skill-rule.md +4 -1
  38. package/prompts/test-agent.md +61 -45
  39. package/prompts/test-reviewer.md +64 -37
  40. package/prompts/test-rules.md +59 -56
  41. package/py/forgeqe_engine/browser.py +111 -1
  42. package/py/forgeqe_engine/capture.py +8 -1
  43. package/py/forgeqe_engine/checks.py +20 -8
  44. package/py/forgeqe_engine/prompts.py +9 -0
  45. package/py/forgeqe_engine/report.py +15 -8
  46. package/py/forgeqe_engine/service.py +50 -28
  47. package/py/forgeqe_engine/skills.py +31 -7
  48. package/src/auth/signin.ts +30 -1
  49. package/src/bi.ts +899 -0
  50. package/src/childEnv.ts +4 -0
  51. package/src/cli/help.ts +10 -2
  52. package/src/cli/index.ts +114 -10
  53. package/src/config.ts +149 -0
  54. package/src/engine/bridge.ts +32 -2
  55. package/src/evals/prompts.ts +288 -0
  56. package/src/mcp/server.ts +19 -1
  57. package/src/mcp/tools.ts +27 -0
  58. package/src/prompts.ts +6 -3
  59. package/src/promptstyle.ts +159 -0
  60. package/src/refine.ts +30 -14
  61. package/src/run.ts +1 -0
  62. package/src/ste.ts +8 -2
  63. package/src/tui/App.tsx +16 -2
  64. package/src/tui/refine-panel.tsx +9 -5
package/src/childEnv.ts CHANGED
@@ -84,3 +84,7 @@ export function privateEnvNames(cfg: AppConfig, extra: Iterable<string> = []): s
84
84
  export function loginEnvNames(logins: { usernameEnv?: string; passwordEnv?: string; totpEnv?: string }[]): string[] {
85
85
  return logins.flatMap((l) => [l.usernameEnv, l.passwordEnv, l.totpEnv]).filter((n): n is string => Boolean(n))
86
86
  }
87
+
88
+ export function browserProviderEnvNames(browser: Pick<AppConfig["browser"], "provider" | "remote">): string[] {
89
+ return browser.provider === "local" || !browser.remote ? [] : [browser.remote.usernameEnv, browser.remote.accessKeyEnv].filter(Boolean)
90
+ }
package/src/cli/help.ts CHANGED
@@ -34,6 +34,10 @@ Usage:
34
34
  forgeqe mcp [--repo DIR] [--profile name] [--url URL] [--headed]
35
35
  ForgeQE's tools as an MCP server on stdio, for other agents
36
36
  forgeqe mcp list the Azure DevOps, Jira and other MCP servers ForgeQE's agent gets
37
+ forgeqe bi [build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR]
38
+ export ForgeQE + ADO/Jira datasets into an Evidence project and build static BI HTML
39
+ forgeqe bi init scaffold or refresh the managed Evidence project files
40
+ forgeqe bi sync refresh the CSV datasets and generated Evidence pages, without building
37
41
  forgeqe pr-tests [pr] [--base BRANCH] [--agent] [--run] [--post]
38
42
  the tests a pull request (or the commits since BRANCH) can break; --run runs
39
43
  them and exits 1 when one fails (a CI gate), --post puts the results on the PR
@@ -67,8 +71,8 @@ Usage:
67
71
  forgeqe api tests [SET] --scenarios[=a,b] --repo DIR
68
72
  scenario tests on the repository's existing API helpers
69
73
  forgeqe api list saved contract sets
70
- forgeqe auth login [system] [--no-browser] sign in to azure_devops, github, jira, confluence, testrail, zephyr, qtest
71
- or xray in the browser, and save the token in the system's credential store
74
+ forgeqe auth login [system] [--no-browser] sign in to azure_devops, github, jira, confluence, testrail, zephyr, qtest,
75
+ xray, browserstack, or testmu in the browser, and save the token in the system's credential store
72
76
  forgeqe auth login <ENV_NAME> save one secret (an LLM API key, a webhook URL) in the credential store
73
77
  forgeqe auth [status] which systems are signed in, and how
74
78
  forgeqe auth logout <system|ENV_NAME> forget a saved sign-in
@@ -127,6 +131,10 @@ With sources.bugs, each failed case files a bug in Jira or Azure DevOps, or comm
127
131
  The report is report.html (step verdicts, timeline with screenshots, timings, environment) + junit.xml in
128
132
  out/suites/<time>/; every run folder also has its own report.html.
129
133
 
134
+ Static BI (bi: in forgeqe.yaml): \`forgeqe bi\` writes curated CSV datasets from ForgeQE's own suite and readiness
135
+ reports plus optional Azure DevOps WIQL and Jira JQL datasets, scaffolds an Evidence project, and runs
136
+ \`evidence build\` to produce static HTML in the build directory. No server stays up between runs.
137
+
130
138
  Healing (forgeqe heal): ForgeQE runs the tests (the command given, code.test_command, or one the agent finds).
131
139
  For each failing test, Scribe re-runs its flow in the real browser: when the flow still works, the test is out
132
140
  of date and it updates the steps and locators from page memory; when the browser fails at the same step, it is an
package/src/cli/index.ts CHANGED
@@ -44,6 +44,8 @@ import { resultsCard, runSelection, selectionCard } from "../code/find.ts"
44
44
  import { listSets as listApiSets } from "../api/store.ts"
45
45
  import { builtinPrompt, lineDiff, listPrompts, prompt, promptDirs, promptFile, promptWarnings } from "../prompts.ts"
46
46
  import { steScore, STE_TARGET } from "../ste.ts"
47
+ import { checkStyle, promptKind, PROMPT_KINDS, RFC_TARGET } from "../promptstyle.ts"
48
+ import { BUILTIN_CASES, loadPromptCases, previousRates, runCase, saveResults, type CaseResult } from "../evals/prompts.ts"
47
49
  import { resultsMarkdown, selectPrTests } from "../code/prtests.ts"
48
50
  import { postPrComment, resolvePr } from "../code/prreview.ts"
49
51
  import { privateEnvNames } from "../childEnv.ts"
@@ -52,9 +54,10 @@ import { featuresText } from "../capabilities/render.ts"
52
54
  import { DOMAIN_CASES, gate, loadDomainCases, reportMarkdown, runDomainEval, saveReport, VARIANTS, type Variant } from "../evals/domain.ts"
53
55
  import { evalNotice, notifiedLine, notify, notifySuite } from "../notify/events.ts"
54
56
  import { sendNotice, type Notice } from "../notify/notice.ts"
57
+ import { buildBi, initBiProject, syncBiData } from "../bi.ts"
55
58
 
56
59
  const AGENT_COMMANDS = new Set(["start", "run", "code", "suite", "heal", "monitor", "explore"])
57
- const LOGGED_COMMANDS = new Set([...AGENT_COMMANDS, "mcp", "login", "setup"])
60
+ const LOGGED_COMMANDS = new Set([...AGENT_COMMANDS, "mcp", "login", "setup", "bi"])
58
61
 
59
62
  /** How much of the agents' work to print: brief unless --verbose. */
60
63
  const detailOf = (flags: Flags) => detailFrom(boolFlag(flags, "verbose"))
@@ -184,8 +187,10 @@ export async function main(argv: string[]): Promise<number> {
184
187
  return await withUsageLine(() => cmdApi(positionals.slice(1), flags))
185
188
  case "explore":
186
189
  return await withUsageLine(() => cmdExplore(positionals.slice(1), flags))
190
+ case "bi":
191
+ return await cmdBi(positionals.slice(1), flags)
187
192
  case "prompts":
188
- return cmdPrompts(positionals.slice(1), flags)
193
+ return positionals[1] === "eval" ? await withUsageLine(() => cmdPromptEval(positionals.slice(2), flags)) : cmdPrompts(positionals.slice(1), flags)
189
194
  case "eval":
190
195
  return await withUsageLine(() => cmdEval(positionals.slice(1), flags))
191
196
  case "notify":
@@ -461,6 +466,50 @@ async function cmdExplore(args: string[], flags: Flags): Promise<number> {
461
466
  }
462
467
  }
463
468
 
469
+ async function cmdBi(args: string[], flags: Flags): Promise<number> {
470
+ const cfg = loadConfig(configOpts(flags))
471
+ const action = args[0] || "build"
472
+ const overrides = {
473
+ evidenceDir: strFlag(flags, "evidence-dir") || strFlag(flags, "dir"),
474
+ dataDir: strFlag(flags, "data-dir"),
475
+ buildDir: strFlag(flags, "build-dir") || strFlag(flags, "out"),
476
+ }
477
+ switch (action) {
478
+ case "init": {
479
+ const out = await initBiProject(cfg, overrides)
480
+ console.log(`Evidence project ${out.evidenceDir}`)
481
+ for (const file of out.files) console.log(`generated ${path.relative(process.cwd(), file) || file}`)
482
+ return 0
483
+ }
484
+ case "sync": {
485
+ const out = await syncBiData(cfg, overrides)
486
+ console.log(`Evidence project ${out.evidenceDir}`)
487
+ console.log(`Data export ${out.dataDir}`)
488
+ console.log(`Build output ${out.buildDir}`)
489
+ console.log(`Suite runs ${out.suites}`)
490
+ console.log(`Case results ${out.caseResults}`)
491
+ console.log(`Readiness reports ${out.readinessReports}`)
492
+ console.log(`External datasets ${out.externalDatasets}`)
493
+ console.log(`Summary ${path.relative(process.cwd(), out.summaryFile) || out.summaryFile}`)
494
+ return 0
495
+ }
496
+ case "build": {
497
+ const out = await buildBi(cfg, overrides)
498
+ console.log(`Evidence project ${out.evidenceDir}`)
499
+ console.log(`Static site ${out.buildDir}`)
500
+ console.log(`Index ${path.relative(process.cwd(), out.indexFile) || out.indexFile}`)
501
+ console.log(`Summary ${path.relative(process.cwd(), out.summaryFile) || out.summaryFile}`)
502
+ console.log(`Suite runs ${out.suites}`)
503
+ console.log(`Case results ${out.caseResults}`)
504
+ console.log(`Readiness reports ${out.readinessReports}`)
505
+ console.log(`External datasets ${out.externalDatasets}`)
506
+ return 0
507
+ }
508
+ default:
509
+ throw new Error("Usage: forgeqe bi [init|sync|build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR]")
510
+ }
511
+ }
512
+
464
513
  /** forgeqe notify test [channel]: a test card to every notification channel (or one), to check each arrives. */
465
514
  async function cmdNotify(args: string[], flags: Flags): Promise<number> {
466
515
  if (args[0] !== "test") throw new Error("Usage: forgeqe notify test [channel]")
@@ -535,6 +584,51 @@ async function cmdEval(args: string[], flags: Flags): Promise<number> {
535
584
  * from (ForgeQE's, or an edited copy in .forgeqe/prompts), how an edited one differs from ForgeQE's, and how closely each
536
585
  * follows ASD-STE100.
537
586
  */
587
+ /**
588
+ * forgeqe prompts eval [prompt] [--case <id>] [--runs <n>]: runs the evaluation cases of the prompts (ForgeQE's, and
589
+ * the project's in .forgeqe/evals/prompts), with checks decided by code, and compares each pass rate with the last run.
590
+ */
591
+ async function cmdPromptEval(args: string[], flags: Flags): Promise<number> {
592
+ const config = loadConfig(configOpts(flags))
593
+ const runs = Math.max(1, Number(strFlag(flags, "runs")) || 1)
594
+ const dirs = [BUILTIN_CASES, ...(config.configPath ? [path.join(path.dirname(config.configPath), ".forgeqe", "evals", "prompts")] : [])]
595
+ const cases = loadPromptCases(dirs, { prompt: args[0], case: strFlag(flags, "case") })
596
+ if (!cases.length) throw new Error(`No evaluation cases${args[0] ? ` for ${args[0]}` : ""}. They are in ${dirs.join(" and ")}, as <prompt>/<case>.yaml.`)
597
+ const before = previousRates(config)
598
+ const results: CaseResult[] = []
599
+ let failed = 0
600
+ for (const c of cases) {
601
+ process.stdout.write(`${c.prompt}/${c.id} … `)
602
+ const r = await runCase(c, config, process.cwd(), runs)
603
+ results.push(r)
604
+ if (r.skipped) {
605
+ console.log(`skipped (${r.skipped})`)
606
+ continue
607
+ }
608
+ const passed = r.runs.filter((x) => x.pass).length
609
+ if (passed < r.runs.length) failed++
610
+ const was = before.get(`${c.prompt}/${c.id}`)
611
+ const tokens = Math.round(r.runs.reduce((a, x) => a + x.tokens, 0) / r.runs.length / 100) / 10
612
+ console.log(`${passed === r.runs.length ? "✓" : "✗"} ${passed}/${r.runs.length}${was ? ` (was ${was[0]}/${was[1]})` : ""} ${tokens}K tokens a run ${c.title}`)
613
+ // Each check that failed in any run, once, with what was seen.
614
+ const seen = new Set<string>()
615
+ for (const run of r.runs) {
616
+ if (run.error) console.log(` error: ${run.error}`)
617
+ for (const ch of run.checks.filter((x) => !x.pass && !seen.has(x.id))) {
618
+ seen.add(ch.id)
619
+ console.log(` ✗ ${ch.id}: ${ch.detail}`)
620
+ }
621
+ }
622
+ const seenText = r.runs.find((x) => x.seen)?.seen
623
+ if (seenText) console.log(` output: ${seenText}`)
624
+ }
625
+ const file = saveResults(config, results)
626
+ const ran = results.filter((r) => !r.skipped)
627
+ const runsAll = ran.flatMap((r) => r.runs)
628
+ console.log(`\n${runsAll.filter((x) => x.pass).length} of ${runsAll.length} runs passed in ${ran.length} cases${results.length > ran.length ? `; ${results.length - ran.length} skipped` : ""}. Results: ${path.relative(process.cwd(), file) || file}`)
629
+ return failed ? 1 : 0
630
+ }
631
+
538
632
  function cmdPrompts(args: string[], flags: Flags): number {
539
633
  loadConfig(configOpts(flags))
540
634
  const action = args[0] || "list"
@@ -547,20 +641,30 @@ function cmdPrompts(args: string[], flags: Flags): number {
547
641
  return 0
548
642
  }
549
643
  if (action === "check") {
550
- // ASD-STE100: each prompt in use (ForgeQE's or the edited copy) scored, and with a name, the sentences to fix.
644
+ // The prompting standard (src/promptstyle.ts) for each prompt in use, ForgeQE's or the edited copy; with a name,
645
+ // every issue to fix.
551
646
  const all = listPrompts().filter((p) => !name || p.name === name)
552
647
  if (name && !all.length) throw new Error(`No prompt named "${name}". forgeqe prompts lists them.`)
553
648
  const width = Math.max(...all.map((p) => p.name.length))
554
- let below = 0
649
+ let failing = 0
555
650
  for (const p of all) {
556
651
  const text = readFileSync(p.file, "utf8").replace(/^---\n[\s\S]*?\n---\n/, "")
557
- const s = steScore(text)
558
- if (s.score < STE_TARGET) below++
559
- console.log(`${s.score >= STE_TARGET ? "✓" : "✗"} ${p.name.padEnd(width)} ${String(Math.round(s.score * 100)).padStart(3)}% ${s.compliant} of ${s.sentences} sentences${p.source === "built-in" ? "" : ` (${p.source})`}`)
560
- if (name) for (const i of s.issues) console.log(` - ${i.issues.map((x) => x.detail).join("; ")}\n ${i.text}`)
652
+ const kind = promptKind(p.kind)
653
+ const r = kind ? checkStyle(text, kind) : undefined
654
+ const issues = r ? r.issues : [{ rule: "kind", detail: `no kind in the header; use one of ${PROMPT_KINDS.join(", ")}` }]
655
+ if (issues.length) failing++
656
+ const pct = (n: number) => `${String(Math.round(n * 100)).padStart(3)}%`
657
+ const facts = r && r.kind !== "user" ? `STE ${pct(r.ste)} RFC 2119 ${r.ruleLines ? pct(r.rfc) : " –"}${r.kind === "structured" ? ` examples ${r.examples}` : ""}` : ""
658
+ console.log(`${issues.length ? "✗" : "✓"} ${p.name.padEnd(width)} ${(kind || "?").padEnd(10)} ${facts}${issues.length ? ` ${issues.length} issue${issues.length === 1 ? "" : "s"}` : ""}${p.source === "built-in" ? "" : ` (${p.source})`}`)
659
+ if (name) {
660
+ for (const i of issues) console.log(` - ${i.rule}: ${i.detail}`)
661
+ for (const i of steScore(text).issues) console.log(` - ste: ${i.issues.map((x) => x.detail).join("; ")}\n ${i.text}`)
662
+ }
561
663
  }
562
- console.log(`\nASD-STE100 writing rules: 20 words or fewer in an instruction, 25 in a description, one instruction per sentence, the active voice in instructions, and approved words. Target: ${Math.round(STE_TARGET * 100)}% of sentences.${name ? "" : " forgeqe prompts check <name> lists the sentences to fix."}`)
563
- return below ? 1 : 0
664
+ console.log(
665
+ `\nThe standard: ASD-STE100 (${Math.round(STE_TARGET * 100)}% of sentences), RFC 2119 key words for rules (${Math.round(RFC_TARGET * 100)}% of rule lines, no lower-case must/should), the sections of each kind, worked examples for structured prompts, and an execution contract for agents.${name ? "" : " forgeqe prompts check <name> lists what to fix."}`,
666
+ )
667
+ return failing ? 1 : 0
564
668
  }
565
669
  if (!name) throw new Error(`Usage: forgeqe prompts ${action} <name> (forgeqe prompts lists the names)`)
566
670
  const where = promptFile(name)
package/src/config.ts CHANGED
@@ -16,7 +16,28 @@ import { currencyCode, currencyForLocale } from "./money.ts"
16
16
  import { listPrice, pricesDate, type DeploymentType, type ListPrice } from "./prices.ts"
17
17
  import { ragConfig, type RagConfig } from "./cases/rag.ts"
18
18
 
19
+ export type BrowserProvider = "local" | "browserstack" | "testmu"
20
+ export type RemoteBrowserProvider = Exclude<BrowserProvider, "local">
21
+
22
+ export type BrowserRemoteConfig = {
23
+ provider: RemoteBrowserProvider
24
+ usernameEnv: string
25
+ accessKeyEnv: string
26
+ endpoint: string
27
+ browser: string
28
+ browserVersion: string
29
+ os?: string
30
+ osVersion?: string
31
+ platform?: string
32
+ project?: string
33
+ build?: string
34
+ name?: string
35
+ local: boolean
36
+ extra?: Record<string, unknown>
37
+ }
38
+
19
39
  export type BrowserConfig = {
40
+ provider: BrowserProvider
20
41
  headless: boolean
21
42
  width: number
22
43
  height: number
@@ -25,6 +46,7 @@ export type BrowserConfig = {
25
46
  visionDetail: "low" | "high"
26
47
  allowedHosts: string[]
27
48
  startUrl?: string
49
+ remote?: BrowserRemoteConfig
28
50
  /** ForgeQE overlay in a visible browser: edge glow, a bar with the current step, the person's input locked. */
29
51
  overlay: boolean
30
52
  /** A second model call checks the finished run against the goal, and its verdict can fail a run the agent completed. */
@@ -176,6 +198,34 @@ export type ApiConfig = {
176
198
  timeoutSeconds: number
177
199
  }
178
200
 
201
+ export type BiDatasetConfig =
202
+ | {
203
+ id: string
204
+ title: string
205
+ kind: "ado_wiql"
206
+ wiql: string
207
+ project?: string
208
+ }
209
+ | {
210
+ id: string
211
+ title: string
212
+ kind: "jira_jql"
213
+ jql: string
214
+ }
215
+
216
+ export type BiConfig = {
217
+ /** Title of the generated Evidence project and its landing page. */
218
+ title: string
219
+ /** Evidence project root, generated and managed by ForgeQE. */
220
+ evidenceDir: string
221
+ /** Exported CSV datasets the Evidence project reads. */
222
+ dataDir: string
223
+ /** Static HTML output from `forgeqe bi build`. */
224
+ buildDir: string
225
+ /** Declarative external datasets to fetch on each sync/build. */
226
+ datasets: BiDatasetConfig[]
227
+ }
228
+
179
229
  export type SuiteConfig = {
180
230
  /** Browser runs at the same time. Coding runs one at a time. */
181
231
  parallel: number
@@ -312,6 +362,8 @@ export type AppConfig = {
312
362
  release?: ReleaseThresholds
313
363
  /** API and contract tests (api: in forgeqe.yaml): see src/api/. */
314
364
  api?: ApiConfig
365
+ /** Offline BI export and the Evidence project that renders it (bi: in forgeqe.yaml). */
366
+ bi?: BiConfig
315
367
  /** Accessibility standard and accessibility/performance budgets (quality: in forgeqe.yaml): see src/quality/. */
316
368
  quality?: QualityConfig
317
369
  /** The product's industry and the regulations in scope (domain: in forgeqe.yaml): see src/domain.ts. */
@@ -371,6 +423,68 @@ function visionDetail(raw: unknown): BrowserConfig["visionDetail"] {
371
423
  return String(raw || "low").toLowerCase() === "high" ? "high" : "low"
372
424
  }
373
425
 
426
+ export function browserProvider(raw: unknown): BrowserProvider {
427
+ const provider = String(raw || "local").trim().toLowerCase().replace(/[\s-]/g, "_")
428
+ if (!provider || provider === "local") return "local"
429
+ if (provider === "browserstack") return "browserstack"
430
+ if (provider === "testmu" || provider === "lambdatest" || provider === "lambda_test") return "testmu"
431
+ throw new Error(`browser.provider must be local, browserstack, or testmu; got "${String(raw)}".`)
432
+ }
433
+
434
+ const REMOTE_BROWSER_DEFAULTS: Record<RemoteBrowserProvider, Pick<BrowserRemoteConfig, "usernameEnv" | "accessKeyEnv" | "endpoint">> = {
435
+ browserstack: {
436
+ usernameEnv: "BROWSERSTACK_USERNAME",
437
+ accessKeyEnv: "BROWSERSTACK_ACCESS_KEY",
438
+ endpoint: "wss://cdp.browserstack.com/playwright",
439
+ },
440
+ testmu: {
441
+ usernameEnv: "LT_USERNAME",
442
+ accessKeyEnv: "LT_ACCESS_KEY",
443
+ endpoint: "wss://cdp.lambdatest.com/playwright",
444
+ },
445
+ }
446
+
447
+ function objectMap(raw: unknown, where: string): Record<string, unknown> | undefined {
448
+ if (raw === undefined || raw === null) return undefined
449
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${where} must be a map of names to values.`)
450
+ return { ...(raw as Record<string, unknown>) }
451
+ }
452
+
453
+ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProvider): BrowserRemoteConfig | undefined {
454
+ if (provider === "local") return undefined
455
+ const remote = asRecord(raw.remote)
456
+ const defaults = REMOTE_BROWSER_DEFAULTS[provider]
457
+ const browser = String(remote.browser || "").trim()
458
+ if (!browser) throw new Error(`browser.remote.browser is required when browser.provider is ${provider}.`)
459
+ const browserVersion = String(remote.browser_version || remote.browserVersion || "latest").trim() || "latest"
460
+ const os = String(remote.os || "").trim() || undefined
461
+ const osVersion = String(remote.os_version || remote.osVersion || "").trim() || undefined
462
+ const platform = String(remote.platform || "").trim() || undefined
463
+ if (provider === "browserstack" && (!os || !osVersion)) {
464
+ throw new Error("browser.remote.os and browser.remote.os_version are required when browser.provider is browserstack.")
465
+ }
466
+ if (provider === "testmu" && !platform && !os) {
467
+ throw new Error("browser.remote.platform is required when browser.provider is testmu, or set browser.remote.os (and optionally os_version).")
468
+ }
469
+ const extra = objectMap(remote.extra ?? remote.capabilities, "browser.remote.extra")
470
+ return {
471
+ provider,
472
+ usernameEnv: String(remote.username_env || remote.usernameEnv || defaults.usernameEnv),
473
+ accessKeyEnv: String(remote.access_key_env || remote.accessKeyEnv || defaults.accessKeyEnv),
474
+ endpoint: String(remote.endpoint || defaults.endpoint).trim().replace(/\/+$/, ""),
475
+ browser,
476
+ browserVersion,
477
+ ...(os ? { os } : {}),
478
+ ...(osVersion ? { osVersion } : {}),
479
+ ...(platform ? { platform } : {}),
480
+ ...(remote.project ? { project: String(remote.project).trim() } : {}),
481
+ ...(remote.build ? { build: String(remote.build).trim() } : {}),
482
+ ...(remote.name ? { name: String(remote.name).trim() } : {}),
483
+ local: remote.local === true || remote.local_testing === true,
484
+ ...(extra && Object.keys(extra).length ? { extra } : {}),
485
+ }
486
+ }
487
+
374
488
  function profileFromRaw(name: string, raw: Record<string, unknown>): LlmProfile {
375
489
  const provider = String(raw.provider || "openai") as LlmProfile["provider"]
376
490
  return {
@@ -549,6 +663,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
549
663
  profiles,
550
664
  llm: profiles[activeProfile] || { ...NO_PROFILE },
551
665
  browser: {
666
+ provider: browserProvider(browser.provider),
552
667
  headless: browser.headless !== false,
553
668
  width: Number(viewport.width || 1280),
554
669
  height: Number(viewport.height || 800),
@@ -557,6 +672,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
557
672
  visionDetail: visionDetail(browser.vision_detail),
558
673
  allowedHosts: hosts,
559
674
  startUrl: browser.start_url ? String(browser.start_url) : undefined,
675
+ remote: browserRemoteConfig(browser, browserProvider(browser.provider)),
560
676
  overlay: browser.overlay !== false,
561
677
  judge: browser.judge === true,
562
678
  },
@@ -600,6 +716,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
600
716
  flaky: flakyConfig(asRecord(yaml.flaky)),
601
717
  release: releaseThresholds(yaml.release),
602
718
  api: apiConfig(asRecord(yaml.api)),
719
+ bi: biConfig(asRecord(yaml.bi), base, outDir, configPath),
603
720
  quality: withBaselineDir(qualityConfig(yaml.quality), base, configPath),
604
721
  domain: domainConfig(yaml.domain),
605
722
  notify: notifyConfig(yaml.notify),
@@ -836,6 +953,38 @@ function apiConfig(a: Record<string, unknown>): ApiConfig {
836
953
  }
837
954
  }
838
955
 
956
+ function biConfig(b: Record<string, unknown>, base: string, outDir: string, configPath: string | undefined): BiConfig {
957
+ const evidenceDir = path.resolve(base, String(b.evidence_dir || path.join(".", "evidence")))
958
+ const defaultDataDir = path.join(evidenceDir, "sources", "forgeqe", "data")
959
+ const title = String(b.title || "ForgeQE BI").trim() || "ForgeQE BI"
960
+ const datasets = Object.entries(asRecord(b.datasets)).map(([id, value]) => biDataset(id, asRecord(value)))
961
+ return {
962
+ title,
963
+ evidenceDir,
964
+ dataDir: path.resolve(base, String(b.data_dir || defaultDataDir)),
965
+ buildDir: path.resolve(base, String(b.build_dir || path.join(outDir, "bi"))),
966
+ datasets,
967
+ }
968
+ }
969
+
970
+ function biDataset(id: string, raw: Record<string, unknown>): BiDatasetConfig {
971
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`bi.datasets.${id}: ids may only use letters, digits, - and _.`)
972
+ const title = String(raw.title || id.replace(/[-_]+/g, " ").trim()).trim()
973
+ const kind = String(raw.kind || "").trim().toLowerCase()
974
+ if (kind === "ado_wiql") {
975
+ const wiql = String(raw.wiql || "").trim()
976
+ if (!wiql) throw new Error(`bi.datasets.${id}.wiql is required for kind ado_wiql.`)
977
+ const project = String(raw.project || "").trim()
978
+ return { id, title: title || id, kind, wiql, ...(project ? { project } : {}) }
979
+ }
980
+ if (kind === "jira_jql") {
981
+ const jql = String(raw.jql || "").trim()
982
+ if (!jql) throw new Error(`bi.datasets.${id}.jql is required for kind jira_jql.`)
983
+ return { id, title: title || id, kind, jql }
984
+ }
985
+ throw new Error(`bi.datasets.${id}.kind must be ado_wiql or jira_jql; got "${String(raw.kind || "")}".`)
986
+ }
987
+
839
988
  function optional<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
840
989
  return value === undefined ? {} : ({ [key]: value } as Record<K, V>)
841
990
  }
@@ -3,9 +3,9 @@ import { createInterface } from "node:readline"
3
3
 
4
4
  import type { LlmProfile } from "../llm.ts"
5
5
  import type { AgentEvent, CaseReport, MemoryInfo, ReplayMode, RunResult } from "../types.ts"
6
- import { loginEnvNames } from "../childEnv.ts"
6
+ import { browserProviderEnvNames, loginEnvNames } from "../childEnv.ts"
7
7
  import { recordUsage } from "../usage.ts"
8
- import type { LoginConfig } from "../config.ts"
8
+ import type { BrowserProvider, BrowserRemoteConfig, LoginConfig } from "../config.ts"
9
9
  import { debugLogging, logEvent } from "../sessionLog.ts"
10
10
  import { engineEnv } from "./setup.ts"
11
11
  import { AZURE_IDENTITY_ENV, entraPayload } from "../entra.ts"
@@ -18,6 +18,7 @@ export type EngineRequest = {
18
18
  headed: boolean
19
19
  maxSteps: number
20
20
  allowedHosts: string[]
21
+ browser?: { provider: BrowserProvider; remote?: BrowserRemoteConfig }
21
22
  extendSystemMessage?: string
22
23
  /** Page memory folder and replay mode; omit to run without memory. */
23
24
  memory?: { dir: string; replay: ReplayMode }
@@ -148,6 +149,33 @@ function llmPayload(p: LlmProfile): Record<string, unknown> {
148
149
  return out
149
150
  }
150
151
 
152
+ function browserPayload(browser: EngineRequest["browser"]): Record<string, unknown> | null {
153
+ if (!browser) return null
154
+ return {
155
+ provider: browser.provider,
156
+ ...(browser.remote
157
+ ? {
158
+ remote: {
159
+ provider: browser.remote.provider,
160
+ username_env: browser.remote.usernameEnv,
161
+ access_key_env: browser.remote.accessKeyEnv,
162
+ endpoint: browser.remote.endpoint,
163
+ browser: browser.remote.browser,
164
+ browser_version: browser.remote.browserVersion,
165
+ ...(browser.remote.os ? { os: browser.remote.os } : {}),
166
+ ...(browser.remote.osVersion ? { os_version: browser.remote.osVersion } : {}),
167
+ ...(browser.remote.platform ? { platform: browser.remote.platform } : {}),
168
+ ...(browser.remote.project ? { project: browser.remote.project } : {}),
169
+ ...(browser.remote.build ? { build: browser.remote.build } : {}),
170
+ ...(browser.remote.name ? { name: browser.remote.name } : {}),
171
+ local: browser.remote.local,
172
+ ...(browser.remote.extra ? { extra: browser.remote.extra } : {}),
173
+ },
174
+ }
175
+ : {}),
176
+ }
177
+ }
178
+
151
179
  export function toRunResult(goal: string, startUrl: string, r: EngineResult): RunResult {
152
180
  // The agent's evidence wins over its verdict: a check that does not hold, or a failed case step, fails the run even
153
181
  // when the agent called done with success (engines before this rule did not apply it themselves).
@@ -198,6 +226,7 @@ export async function runEngine(opts: {
198
226
  const child = spawn(bin!, args, {
199
227
  env: engineEnv(debugLogging() ? { ...req.env, BROWSER_USE_LOGGING_LEVEL: "debug" } : req.env, [
200
228
  ...(req.llm.auth === "entra" ? AZURE_IDENTITY_ENV : req.llm.apiKeyEnv ? [req.llm.apiKeyEnv] : []),
229
+ ...browserProviderEnvNames(req.browser || { provider: "local" }),
201
230
  ...loginEnvNames(req.logins || []),
202
231
  ]),
203
232
  stdio: ["pipe", "pipe", "pipe"],
@@ -222,6 +251,7 @@ export async function runEngine(opts: {
222
251
  headed: req.headed,
223
252
  max_steps: req.maxSteps,
224
253
  allowed_hosts: req.allowedHosts,
254
+ browser: browserPayload(req.browser),
225
255
  extend_system_message: req.extendSystemMessage || null,
226
256
  memory: req.memory || null,
227
257
  logins: loginPayload(req.logins),