@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.
- package/HOW_TO_USE.md +30 -13
- package/README.md +27 -1
- package/docs/user-guide.html +33 -13
- 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 +283 -266
- 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 -36
- 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 +19 -1
- package/src/mcp/tools.ts +27 -0
- package/src/prompts.ts +6 -3
- package/src/promptstyle.ts +159 -0
- package/src/refine.ts +30 -14
- package/src/run.ts +1 -0
- package/src/ste.ts +8 -2
- package/src/tui/App.tsx +16 -2
- package/src/tui/refine-panel.tsx +9 -5
|
@@ -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
|
|
@@ -54,8 +62,18 @@ export function createMcpServer(opts: McpServerOptions) {
|
|
|
54
62
|
base.emit?.(e)
|
|
55
63
|
}
|
|
56
64
|
: undefined
|
|
57
|
-
const
|
|
65
|
+
const said = (ok: boolean) => {
|
|
66
|
+
if (tool.line && emit) emit({ type: "call", text: tool.line(params.arguments || {}), ok })
|
|
67
|
+
}
|
|
68
|
+
let out: unknown
|
|
69
|
+
try {
|
|
70
|
+
out = await tool.run(params.arguments || {}, { ...base, emit, signal: controller.signal })
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (!controller.signal.aborted) said(false)
|
|
73
|
+
throw err
|
|
74
|
+
}
|
|
58
75
|
if (controller.signal.aborted) return finish({ content: [{ type: "text", text: "Stopped by the user." }], isError: true })
|
|
76
|
+
said(true)
|
|
59
77
|
return finish({ content: [{ type: "text", text: typeof out === "string" ? out : JSON.stringify(out, null, 2) }] })
|
|
60
78
|
} catch (err) {
|
|
61
79
|
return finish({ content: [{ type: "text", text: controller.signal.aborted ? "Stopped by the user." : (err as Error).message || String(err) }], isError: true })
|
package/src/mcp/tools.ts
CHANGED
|
@@ -98,6 +98,25 @@ export type ToolEvent =
|
|
|
98
98
|
| { type: "card"; card: Card }
|
|
99
99
|
/** write_report saved a report: its page and its Markdown. */
|
|
100
100
|
| { type: "report"; title: string; html: string; markdown: string }
|
|
101
|
+
/** A finished call of a tool that has no card of its own, as one line: "Azure DevOps API · QI-Claims/_apis/build/builds (top 5)". */
|
|
102
|
+
| { type: "call"; text: string; ok: boolean }
|
|
103
|
+
|
|
104
|
+
const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira: "Jira", xray: "Xray", confluence: "Confluence" }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A query_api call as a line: "Azure DevOps API · QI-Claims/_apis/release/releases (top 1, queryOrder descending)". The
|
|
108
|
+
* host and organization are left out, as are api-version and the start of a WIQL query.
|
|
109
|
+
*/
|
|
110
|
+
export function queryLine(args: Record<string, any>): string {
|
|
111
|
+
const name = `${API_LABEL[String(args.system)] || String(args.system || "API")} API`
|
|
112
|
+
if (args.wiql) return `${name} · WIQL (${clip(String(args.wiql).replace(/\s+/g, " ").replace(/^SELECT .*? FROM WorkItems WHERE /i, ""), 90)})`
|
|
113
|
+
if (args.query) return `${name} · GraphQL (${clip(String(args.query).replace(/\s+/g, " "), 90)})`
|
|
114
|
+
const raw = String(args.path || "")
|
|
115
|
+
const url = /^https?:\/\//.test(raw) ? raw.replace(/^https?:\/\/[^/]+\/(?:[^/]+\/(?=[^/]+\/_apis\/))?/, "") : raw.replace(/^\//, "")
|
|
116
|
+
const [where, search = ""] = url.split("?")
|
|
117
|
+
const params = [...new URLSearchParams(search)].filter(([k]) => k !== "api-version").map(([k, v]) => `${k.replace(/^\$/, "")} ${v}`)
|
|
118
|
+
return `${name} · ${decodeURIComponent(where || "")}${params.length ? ` (${clip(params.join(", "), 90)})` : ""}`
|
|
119
|
+
}
|
|
101
120
|
|
|
102
121
|
export type ToolContext = {
|
|
103
122
|
config: AppConfig
|
|
@@ -111,6 +130,10 @@ export type ToolContext = {
|
|
|
111
130
|
url?: string
|
|
112
131
|
signal?: AbortSignal
|
|
113
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
|
|
114
137
|
/** How long this process lives: the shell, one `forgeqe run`, or `forgeqe mcp`. Background monitors need shell or server. */
|
|
115
138
|
session?: "shell" | "once" | "server"
|
|
116
139
|
/** Passed browser runs become tests in cwd (the shell and `forgeqe run` without --no-code). */
|
|
@@ -135,6 +158,8 @@ export type McpTool = {
|
|
|
135
158
|
description: string
|
|
136
159
|
inputSchema: Record<string, unknown>
|
|
137
160
|
run(args: Record<string, any>, ctx: ToolContext): Promise<unknown>
|
|
161
|
+
/** The call as one line in the conversation, for a tool that shows nothing else: the ForgeQE tools are hidden there. */
|
|
162
|
+
line?(args: Record<string, any>): string
|
|
138
163
|
}
|
|
139
164
|
|
|
140
165
|
const clip = (s: string | undefined, n: number) => {
|
|
@@ -563,6 +588,7 @@ export const TOOLS: McpTool[] = [
|
|
|
563
588
|
limit: { type: "integer", minimum: 1, maximum: 25, description: "Most pages to return (default 10)" },
|
|
564
589
|
},
|
|
565
590
|
},
|
|
591
|
+
line: (args) => `Confluence · ${args.cql ? `search (${clip(String(args.cql), 90)})` : `pages (${clip((Array.isArray(args.pages) ? args.pages : []).map(String).join(", "), 90)})`}`,
|
|
566
592
|
async run(args, ctx) {
|
|
567
593
|
const pages = Array.isArray(args.pages) ? args.pages.map(String) : undefined
|
|
568
594
|
return readConfluence(ctx.config.sources, { pages, cql: args.cql ? String(args.cql) : undefined, limit: Number(args.limit) || undefined })
|
|
@@ -592,6 +618,7 @@ export const TOOLS: McpTool[] = [
|
|
|
592
618
|
},
|
|
593
619
|
required: ["system"],
|
|
594
620
|
},
|
|
621
|
+
line: queryLine,
|
|
595
622
|
async run(args, ctx) {
|
|
596
623
|
return queryApi(ctx.config.sources, { system: args.system, path: args.path, query: args.query, variables: args.variables, wiql: args.wiql, project: args.project })
|
|
597
624
|
},
|
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
|
+
}
|