@capagents/forgeqe 1.1.1 → 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.
- package/HOW_TO_USE.md +20 -4
- package/README.md +27 -1
- package/docs/user-guide.html +24 -4
- 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 +39 -0
- package/package.json +1 -1
- 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 -28
- package/py/forgeqe_engine/skills.py +31 -7
- package/src/auth/signin.ts +30 -1
- package/src/bi.ts +899 -0
- package/src/childEnv.ts +4 -0
- package/src/cli/help.ts +10 -2
- package/src/cli/index.ts +114 -10
- package/src/config.ts +149 -0
- package/src/engine/bridge.ts +32 -2
- package/src/evals/prompts.ts +288 -0
- package/src/mcp/server.ts +8 -0
- package/src/mcp/tools.ts +4 -0
- package/src/prompts.ts +6 -3
- package/src/promptstyle.ts +159 -0
- package/src/run.ts +1 -0
- package/src/ste.ts +8 -2
|
@@ -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/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
|
@@ -130,6 +130,10 @@ export type ToolContext = {
|
|
|
130
130
|
url?: string
|
|
131
131
|
signal?: AbortSignal
|
|
132
132
|
emit?: (e: ToolEvent) => void
|
|
133
|
+
/** Sees every call before it runs, with its arguments: evaluations record which tools the agent chose. */
|
|
134
|
+
observe?: (tool: string, args: Record<string, unknown>) => void
|
|
135
|
+
/** Whether a tool may run; a refused call answers that it is not available here (evaluations refuse tools that act). */
|
|
136
|
+
allow?: (tool: string) => boolean
|
|
133
137
|
/** How long this process lives: the shell, one `forgeqe run`, or `forgeqe mcp`. Background monitors need shell or server. */
|
|
134
138
|
session?: "shell" | "once" | "server"
|
|
135
139
|
/** Passed browser runs become tests in cwd (the shell and `forgeqe run` without --no-code). */
|
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
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { steParagraphs, steScore, STE_TARGET, RFC_LEAD } from "./ste.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The prompting standard every ForgeQE prompt follows, and what of it can be checked in the text. Each technique is
|
|
5
|
+
* used where it fits the kind of prompt (frontmatter `kind`):
|
|
6
|
+
*
|
|
7
|
+
* | kind | what it is | structure | few-shot | execution contract |
|
|
8
|
+
* |------------|------------------------------------------------|-------------------------------------------------------------|----------|--------------------|
|
|
9
|
+
* | agent | works with tools over many steps | Role, Objective, Execution contract, Rules, Output | optional | required |
|
|
10
|
+
* | structured | one answer in a fixed shape (plans, verdicts) | Role, Objective, Inputs, Rules, Output, Examples | required | no |
|
|
11
|
+
* | fragment | rules included in other prompts | Rules | no | no |
|
|
12
|
+
* | user | the user's own text (browser-extra) | not checked | no | no |
|
|
13
|
+
*
|
|
14
|
+
* Every kind but user is written in ASD-STE100 (src/ste.ts) and states its rules with the RFC 2119 key words: MUST and
|
|
15
|
+
* MUST NOT for absolute rules, SHOULD and SHOULD NOT for strong defaults, MAY for a free choice. A lower-case "must",
|
|
16
|
+
* "should", or "shall" is ambiguous, so it is flagged. Evaluation cases (evals/prompts/<name>/) measure how reliably
|
|
17
|
+
* a prompt does its task; `forgeqe prompts eval` runs them.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type PromptKind = "agent" | "structured" | "fragment" | "user"
|
|
21
|
+
export const PROMPT_KINDS: PromptKind[] = ["agent", "structured", "fragment", "user"]
|
|
22
|
+
|
|
23
|
+
/** The `##` sections each kind has, in this order. Other sections may come between them. */
|
|
24
|
+
export const SECTIONS: Record<PromptKind, string[]> = {
|
|
25
|
+
agent: ["Role", "Objective", "Execution contract", "Rules", "Output"],
|
|
26
|
+
structured: ["Role", "Objective", "Inputs", "Rules", "Output", "Examples"],
|
|
27
|
+
fragment: [],
|
|
28
|
+
user: [],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The parts of an execution contract, each a bold label or a `###` heading inside it. */
|
|
32
|
+
export const CONTRACT_PARTS = ["Preconditions", "Tools", "Procedure", "Stop when", "On failure", "Evidence"]
|
|
33
|
+
|
|
34
|
+
/** Fewest worked examples a structured prompt gives. */
|
|
35
|
+
export const MIN_EXAMPLES = 2
|
|
36
|
+
|
|
37
|
+
/** Share of rule lines (list items under a Rules section) that carry an RFC 2119 key word. */
|
|
38
|
+
export const RFC_TARGET = 0.8
|
|
39
|
+
|
|
40
|
+
const KEYWORD = /\b(MUST NOT|MUST|SHOULD NOT|SHOULD|MAY|SHALL NOT|SHALL|REQUIRED|RECOMMENDED|OPTIONAL)\b/
|
|
41
|
+
/** Lower-case words that read like a rule but say nothing of its strength. */
|
|
42
|
+
const AMBIGUOUS = /\b(must|should|shall)\b/
|
|
43
|
+
|
|
44
|
+
export type StyleIssue = { rule: "ste" | "rfc" | "ambiguous" | "section" | "order" | "examples" | "contract" | "kind"; detail: string }
|
|
45
|
+
|
|
46
|
+
export type StyleReport = {
|
|
47
|
+
kind: PromptKind
|
|
48
|
+
ste: number
|
|
49
|
+
/** Rule lines with a key word, of all rule lines; 1 when the prompt has none. */
|
|
50
|
+
rfc: number
|
|
51
|
+
ruleLines: number
|
|
52
|
+
examples: number
|
|
53
|
+
issues: StyleIssue[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The `##` headings of a prompt, in order, without code blocks. */
|
|
57
|
+
export function headings(markdown: string, level = 2): string[] {
|
|
58
|
+
const out: string[] = []
|
|
59
|
+
let fence = false
|
|
60
|
+
const mark = `${"#".repeat(level)} `
|
|
61
|
+
for (const line of markdown.split("\n")) {
|
|
62
|
+
if (/^\s*(```|~~~)/.test(line)) fence = !fence
|
|
63
|
+
else if (!fence && line.startsWith(mark)) out.push(line.slice(mark.length).trim())
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The text under a `##` heading, up to the next `##` heading. */
|
|
69
|
+
export function section(markdown: string, name: string): string {
|
|
70
|
+
const lines = markdown.split("\n")
|
|
71
|
+
const at = lines.findIndex((l) => l.trim().toLowerCase() === `## ${name}`.toLowerCase())
|
|
72
|
+
if (at < 0) return ""
|
|
73
|
+
const end = lines.findIndex((l, i) => i > at && /^## /.test(l))
|
|
74
|
+
return lines.slice(at + 1, end < 0 ? undefined : end).join("\n")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Text with code, quotes, and placeholders taken out: a quoted example is not a rule. */
|
|
78
|
+
const prose = (text: string) =>
|
|
79
|
+
text
|
|
80
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
81
|
+
.replace(/`[^`]*`/g, "")
|
|
82
|
+
.replace(/"[^"\n]*"/g, "")
|
|
83
|
+
.replace(/“[^”\n]*”/g, "")
|
|
84
|
+
.replace(/\{\{[^}]*\}\}/g, "")
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The rules of the Rules sections (and of a fragment's whole text). A list item is one rule, with what is nested under
|
|
88
|
+
* it as its details; only an item without a key word that opens a list ("Write replies so that they read quickly:")
|
|
89
|
+
* hands its nested items on, each a rule of its own.
|
|
90
|
+
*/
|
|
91
|
+
export function ruleLines(markdown: string, kind: PromptKind): string[] {
|
|
92
|
+
const parts = kind === "fragment" ? [markdown] : headings(markdown).filter((h) => /rules/i.test(h)).map((h) => section(markdown, h))
|
|
93
|
+
const items: string[] = []
|
|
94
|
+
for (const part of parts) {
|
|
95
|
+
// The indent of the items that count as rules at each depth; deeper items under a rule are its details.
|
|
96
|
+
let ruleIndent = -1
|
|
97
|
+
for (const line of prose(part).split("\n")) {
|
|
98
|
+
const m = /^(\s*)(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line)
|
|
99
|
+
if (!m) continue
|
|
100
|
+
const indent = m[1]!.length
|
|
101
|
+
const text = m[2]!.trim()
|
|
102
|
+
if (!text) continue
|
|
103
|
+
if (ruleIndent >= 0 && indent > ruleIndent) continue
|
|
104
|
+
// A numbered step is a MUST by the conventions, unless it says SHOULD or MAY.
|
|
105
|
+
const numbered = /^\s*\d+[.)]/.test(line)
|
|
106
|
+
const opensList = /:\s*$/.test(text) && !KEYWORD.test(text) && !numbered
|
|
107
|
+
if (opensList) {
|
|
108
|
+
ruleIndent = -1
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
items.push(numbered && !KEYWORD.test(text) ? `MUST ${text}` : text)
|
|
112
|
+
ruleIndent = indent
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return items
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function checkStyle(markdown: string, kind: PromptKind): StyleReport {
|
|
119
|
+
const issues: StyleIssue[] = []
|
|
120
|
+
if (kind === "user") return { kind, ste: 1, rfc: 1, ruleLines: 0, examples: 0, issues }
|
|
121
|
+
const ste = steScore(markdown).score
|
|
122
|
+
if (ste < STE_TARGET) issues.push({ rule: "ste", detail: `${Math.round(ste * 100)}% of sentences keep ASD-STE100; the target is ${Math.round(STE_TARGET * 100)}%` })
|
|
123
|
+
|
|
124
|
+
const rules = ruleLines(markdown, kind)
|
|
125
|
+
const keyed = rules.filter((r) => KEYWORD.test(r)).length
|
|
126
|
+
const rfc = rules.length ? keyed / rules.length : 1
|
|
127
|
+
if (rules.length && rfc < RFC_TARGET) issues.push({ rule: "rfc", detail: `${keyed} of ${rules.length} rules use an RFC 2119 key word; the target is ${Math.round(RFC_TARGET * 100)}%` })
|
|
128
|
+
for (const sentence of steParagraphs(prose(markdown)).flat()) {
|
|
129
|
+
const m = AMBIGUOUS.exec(sentence)
|
|
130
|
+
if (m) issues.push({ rule: "ambiguous", detail: `"${m[0]}" in: ${sentence.slice(0, 100)} — use ${m[0].toUpperCase()} (RFC 2119), or say it as a fact` })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const have = headings(markdown)
|
|
134
|
+
const need = SECTIONS[kind]
|
|
135
|
+
for (const name of need) if (!have.some((h) => h.toLowerCase() === name.toLowerCase())) issues.push({ rule: "section", detail: `no "## ${name}" section` })
|
|
136
|
+
const order = need.map((n) => have.findIndex((h) => h.toLowerCase() === n.toLowerCase())).filter((i) => i >= 0)
|
|
137
|
+
if (order.some((v, i) => i > 0 && v < order[i - 1]!)) issues.push({ rule: "order", detail: `sections out of order; use ${need.join(", ")}` })
|
|
138
|
+
|
|
139
|
+
const examples = headings(markdown, 3).filter((h) => /^example\b/i.test(h)).length
|
|
140
|
+
if (kind === "structured" && examples < MIN_EXAMPLES) issues.push({ rule: "examples", detail: `${examples} worked examples ("### Example …"); a structured prompt gives ${MIN_EXAMPLES} or more` })
|
|
141
|
+
|
|
142
|
+
if (kind === "agent") {
|
|
143
|
+
const contract = section(markdown, "Execution contract")
|
|
144
|
+
for (const part of CONTRACT_PARTS) {
|
|
145
|
+
const label = new RegExp(`(\\*\\*${part}\\*\\*|^###\\s+${part}\\b)`, "im")
|
|
146
|
+
if (contract && !label.test(contract)) issues.push({ rule: "contract", detail: `the execution contract has no "${part}" part` })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { kind, ste, rfc, ruleLines: rules.length, examples, issues }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function promptKind(value: string | undefined): PromptKind | undefined {
|
|
153
|
+
return PROMPT_KINDS.find((k) => k === value)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Whether a sentence states a rule with an RFC 2119 key word ("You MUST …", "… MUST NOT …"). */
|
|
157
|
+
export function isRule(sentence: string): boolean {
|
|
158
|
+
return RFC_LEAD.test(sentence.trim()) || KEYWORD.test(sentence)
|
|
159
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -216,6 +216,7 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
|
|
|
216
216
|
headed: Boolean(opts.headed) || !opts.config.browser.headless,
|
|
217
217
|
maxSteps,
|
|
218
218
|
allowedHosts: allowed,
|
|
219
|
+
browser: { provider: opts.config.browser.provider, remote: opts.config.browser.remote },
|
|
219
220
|
extendSystemMessage: extend || undefined,
|
|
220
221
|
memory,
|
|
221
222
|
env,
|
package/src/ste.ts
CHANGED
|
@@ -91,8 +91,13 @@ export function countWords(text: string): number {
|
|
|
91
91
|
return (collapse(text).match(/[A-Za-z0-9][\w'’./-]*/g) || []).length
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** "You MUST", "The agent SHOULD NOT": the RFC 2119 key word that makes a sentence a rule, with its subject. */
|
|
95
|
+
export const RFC_LEAD = /^(([Yy]ou|[Tt]he agent|[Tt]he reply|[Ee]ach \w+|[Ee]very \w+)\s+)?(MUST NOT|MUST|SHOULD NOT|SHOULD|MAY|SHALL NOT|SHALL)\s+/
|
|
96
|
+
|
|
94
97
|
export function isInstruction(sentence: string): boolean {
|
|
95
|
-
const
|
|
98
|
+
const lead = collapse(sentence).replace(/^[\s>*_-]+/, "").replace(LEAD, "")
|
|
99
|
+
// "You MUST run the tests." is an instruction: the verb after the key word decides, as without it.
|
|
100
|
+
const s = RFC_LEAD.test(lead) ? lead.replace(RFC_LEAD, "") : lead
|
|
96
101
|
const first = (s.match(/^[A-Za-z']+/)?.[0] || "").toLowerCase()
|
|
97
102
|
return VERBS.has(first)
|
|
98
103
|
}
|
|
@@ -151,7 +156,8 @@ function splitSentences(text: string): string[] {
|
|
|
151
156
|
}
|
|
152
157
|
|
|
153
158
|
export function checkSentence(text: string): SteSentence {
|
|
154
|
-
|
|
159
|
+
// The subject and RFC 2119 key word of a rule ("You MUST NOT") do not count toward its length.
|
|
160
|
+
const words = countWords(text) - countWords(collapse(text).replace(/^[\s>*_-]+/, "").replace(LEAD, "").match(RFC_LEAD)?.[0] || "")
|
|
155
161
|
const instruction = isInstruction(text)
|
|
156
162
|
const issues: SteSentence["issues"] = []
|
|
157
163
|
const limit = instruction ? 20 : 25
|