@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.
- package/HOW_TO_USE.md +25 -9
- package/README.md +30 -1
- package/browserstack.example.yaml +14 -0
- package/docs/security-data-flow.html +154 -86
- package/docs/user-guide.html +329 -135
- package/evals/prompts/brain/capability-question.yaml +12 -0
- package/evals/prompts/brain/failed-runs-cause.yaml +12 -0
- package/evals/prompts/brain/release-route.yaml +14 -0
- package/evals/prompts/brain/report-request.yaml +13 -0
- package/evals/prompts/brain/secret-request.yaml +9 -0
- package/evals/prompts/explore-charters/lockout.yaml +18 -0
- package/evals/prompts/explore-charters/transfer-limit.yaml +16 -0
- package/evals/prompts/refine/current-release.yaml +26 -0
- package/evals/prompts/refine/missing-item.yaml +10 -0
- package/evals/prompts/refine/question-stays-question.yaml +13 -0
- package/evals/prompts/refine/report-request.yaml +19 -0
- package/forgeqe.example.yaml +51 -0
- package/package.json +5 -2
- package/prompts/README.md +28 -7
- package/prompts/api-scenarios.md +56 -24
- package/prompts/api-tests.md +67 -51
- package/prompts/brain.md +277 -268
- package/prompts/browser-capture.md +8 -7
- package/prompts/browser-case-report.md +13 -12
- package/prompts/browser-checks.md +17 -18
- package/prompts/browser-efficiency.md +4 -1
- package/prompts/browser-error-recovery.md +5 -4
- package/prompts/browser-extra.md +1 -0
- package/prompts/browser-goal-verification.md +8 -7
- package/prompts/browser-skill-select.md +28 -1
- package/prompts/conventions.md +10 -0
- package/prompts/domain-rule.md +17 -14
- package/prompts/eval-judge.md +36 -13
- package/prompts/explore-charters.md +53 -22
- package/prompts/find-tests.md +45 -30
- package/prompts/heal.md +42 -32
- package/prompts/pr-review.md +44 -28
- package/prompts/refine.md +73 -40
- package/prompts/skill-rule.md +4 -1
- package/prompts/test-agent.md +61 -45
- package/prompts/test-reviewer.md +64 -37
- package/prompts/test-rules.md +59 -56
- package/py/forgeqe_engine/browser.py +111 -1
- package/py/forgeqe_engine/capture.py +8 -1
- package/py/forgeqe_engine/checks.py +20 -8
- package/py/forgeqe_engine/prompts.py +9 -0
- package/py/forgeqe_engine/report.py +15 -8
- package/py/forgeqe_engine/service.py +50 -27
- package/py/forgeqe_engine/skills.py +31 -7
- package/src/auth/signin.ts +30 -1
- package/src/bi.ts +914 -0
- package/src/cache.ts +92 -0
- package/src/capabilities/guide.ts +322 -140
- package/src/capabilities/registry.ts +3 -3
- package/src/childEnv.ts +4 -0
- package/src/cli/help.ts +18 -4
- package/src/cli/index.ts +149 -15
- package/src/config.ts +272 -0
- package/src/engine/bridge.ts +32 -2
- package/src/evals/prompts.ts +288 -0
- package/src/heal/loop.ts +3 -1
- package/src/init.ts +18 -0
- package/src/mcp/server.ts +8 -0
- package/src/mcp/tools.ts +30 -4
- package/src/monitor/run.ts +3 -1
- package/src/prompts.ts +6 -3
- package/src/promptstyle.ts +159 -0
- package/src/run.ts +4 -1
- package/src/ste.ts +8 -2
- package/src/suite/run.ts +3 -1
- 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
|
|
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),
|
package/src/monitor/run.ts
CHANGED
|
@@ -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
|
-
|
|
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
|