@capagents/forgeqe 1.3.1 → 2.0.0
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 +373 -9
- package/README.md +55 -4
- package/docs/ARCHITECTURE.md +62 -1
- package/docs/CONTAINERS.md +227 -0
- package/docs/COST_ARCHITECTURE.md +32 -0
- package/docs/DESKTOP-CI.md +87 -0
- package/docs/DESKTOP.md +387 -0
- package/docs/MOBILE.md +228 -0
- package/docs/feature-test-prompts.md +25 -0
- package/docs/security-data-flow.html +440 -3
- package/docs/user-guide.html +797 -415
- package/env.example.yaml +67 -0
- package/forgeqe.example.yaml +56 -1
- package/llms.example.json +7 -0
- package/package.json +4 -1
- package/prompts/brain.md +31 -0
- package/prompts/find-tests.md +1 -0
- package/py/forgeqe_desktop/__init__.py +1 -0
- package/py/forgeqe_desktop/__main__.py +3 -0
- package/py/forgeqe_desktop/apps.py +80 -0
- package/py/forgeqe_desktop/checks.py +168 -0
- package/py/forgeqe_desktop/cli.py +90 -0
- package/py/forgeqe_desktop/handhooks.py +293 -0
- package/py/forgeqe_desktop/handrecord.py +239 -0
- package/py/forgeqe_desktop/heal.py +107 -0
- package/py/forgeqe_desktop/models.py +208 -0
- package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
- package/py/forgeqe_desktop/record.py +150 -0
- package/py/forgeqe_desktop/replay.py +345 -0
- package/py/forgeqe_desktop/service.py +356 -0
- package/py/forgeqe_desktop/session.py +60 -0
- package/py/forgeqe_desktop/ufo_bridge.py +86 -0
- package/py/forgeqe_desktop/ufo_runner.py +305 -0
- package/py/forgeqe_desktop/visual.py +112 -0
- package/py/forgeqe_desktop/workflow.py +338 -0
- package/py/forgeqe_engine/cli.py +10 -0
- package/py/forgeqe_engine/llm.py +54 -4
- package/py/forgeqe_engine/model_params.py +169 -0
- package/py/forgeqe_engine/summary.py +6 -0
- package/py/forgeqe_mobile/__init__.py +1 -0
- package/py/forgeqe_mobile/__main__.py +3 -0
- package/py/forgeqe_mobile/appium_driver.py +342 -0
- package/py/forgeqe_mobile/apps.py +84 -0
- package/py/forgeqe_mobile/cli.py +70 -0
- package/py/forgeqe_mobile/explore.py +203 -0
- package/py/forgeqe_mobile/hyperexecute.py +261 -0
- package/py/forgeqe_mobile/maestro.py +321 -0
- package/py/forgeqe_mobile/service.py +332 -0
- package/skills/testcontainers/SKILL.md +42 -0
- package/src/agents.ts +11 -2
- package/src/api/plan.ts +2 -0
- package/src/api/write.ts +2 -0
- package/src/auth/copilot.ts +130 -0
- package/src/auth/signin.ts +54 -1
- package/src/brain/index.ts +26 -4
- package/src/capabilities/guide.ts +2 -0
- package/src/capabilities/registry.ts +110 -2
- package/src/capabilities/status.ts +15 -0
- package/src/cases/defects.ts +2 -1
- package/src/cases/rca.ts +2 -0
- package/src/childEnv.ts +4 -1
- package/src/cli/args.ts +3 -2
- package/src/cli/help.ts +24 -3
- package/src/cli/index.ts +47 -3
- package/src/code/find.ts +26 -0
- package/src/code/loop.ts +4 -1
- package/src/code/prompt.ts +2 -1
- package/src/code/prreview.ts +2 -0
- package/src/code/review.ts +6 -2
- package/src/code/runtime.ts +34 -3
- package/src/commands.ts +6 -1
- package/src/config.ts +159 -3
- package/src/containers/command.ts +68 -0
- package/src/containers/compose.ts +173 -0
- package/src/containers/config.ts +283 -0
- package/src/containers/mock.ts +156 -0
- package/src/containers/run.ts +362 -0
- package/src/containers/tests.ts +80 -0
- package/src/context.ts +33 -6
- package/src/copilot.ts +155 -0
- package/src/desktop/bridge.ts +229 -0
- package/src/desktop/command.ts +350 -0
- package/src/desktop/edit.ts +217 -0
- package/src/desktop/junit.ts +35 -0
- package/src/desktop/report.ts +155 -0
- package/src/desktop/run.ts +252 -0
- package/src/desktop/setup.ts +134 -0
- package/src/engine/bridge.ts +12 -5
- package/src/engine/setup.ts +6 -3
- package/src/environments.ts +566 -0
- package/src/evals/domain.ts +1 -0
- package/src/explore/charters.ts +2 -0
- package/src/guardrails.ts +145 -0
- package/src/heal/loop.ts +4 -1
- package/src/heal/prompt.ts +2 -1
- package/src/init.ts +5 -2
- package/src/llm.ts +23 -2
- package/src/mcp/attachtools.ts +16 -4
- package/src/mcp/captools.ts +27 -0
- package/src/mcp/containertools.ts +212 -0
- package/src/mcp/desktoptools.ts +334 -0
- package/src/mcp/mobiletools.ts +132 -0
- package/src/mcp/reporttools.ts +17 -2
- package/src/mcp/repotests.ts +22 -3
- package/src/mcp/routing.ts +11 -5
- package/src/mcp/scope.ts +210 -0
- package/src/mcp/server.ts +80 -5
- package/src/mcp/tools.ts +38 -1
- package/src/mobile/command.ts +245 -0
- package/src/mobile/run.ts +226 -0
- package/src/pack/pipeline.ts +44 -2
- package/src/refine.ts +3 -0
- package/src/report/html.ts +3 -0
- package/src/report/template.html +48 -48
- package/src/run.ts +7 -3
- package/src/tokenomics/ledger.ts +3 -1
- package/src/tui/App.tsx +23 -0
- package/src/update.ts +118 -8
- package/src/usage.ts +1 -1
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import type { DesktopEvent, DesktopResult } from "../desktop/bridge.ts"
|
|
2
|
+
import { mkdirSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { engineValidate, proposeEdit, writeEdit } from "../desktop/edit.ts"
|
|
6
|
+
import { allowedWorkflow, checkApp, checkDesktopConfig, checkVariables, desktopOutDir, exploreGoal, hasModel, privateDesktopSecrets, recordByHand, recordingNotes, replayWorkflow, visionProblem } from "../desktop/run.ts"
|
|
7
|
+
import { NOT_WINDOWS, desktopDoctor } from "../desktop/setup.ts"
|
|
8
|
+
import { activeEnvironment, environmentBrief, exportEnvironment, withEnvironment } from "../environments.ts"
|
|
9
|
+
import type { McpTool, ToolContext } from "./tools.ts"
|
|
10
|
+
|
|
11
|
+
/** The apps of the test environments, and Windows thick-client apps (docs/DESKTOP.md): explore a goal, replay a saved workflow. */
|
|
12
|
+
|
|
13
|
+
const clip = (s: string | undefined, n: number) => {
|
|
14
|
+
const t = (s || "").trim()
|
|
15
|
+
return t.length <= n ? t : `${t.slice(0, n - 1)}…`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function onWindows(platform: string = process.platform): string | null {
|
|
19
|
+
return platform === "win32" ? null : NOT_WINDOWS
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function stepEvents(ctx: ToolContext): (e: DesktopEvent) => void {
|
|
23
|
+
return (e) => {
|
|
24
|
+
if (e.type === "step") ctx.emit?.({ type: "info", text: `${e.ok ? "✓" : "✗"} ${e.index} ${e.op}${e.healed_target ? " (healed)" : ""}${e.error ? `: ${clip(e.error, 200)}` : ""}`, ok: e.ok })
|
|
25
|
+
else if (e.type === "log" && e.text && e.level !== "debug") ctx.emit?.({ type: "info", text: clip(e.text, 300), ...(e.level === "error" ? { ok: false } : {}) })
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Ask the user before the desktop's mouse and keyboard are taken over; where ForgeQE cannot ask, the caller confirms. */
|
|
30
|
+
export async function confirmed(ctx: ToolContext, args: Record<string, any>, title: string, detail: string): Promise<{ go: true } | { go: false; answer: Record<string, unknown> }> {
|
|
31
|
+
if (ctx.ask) {
|
|
32
|
+
const choice = await ctx.ask({
|
|
33
|
+
title,
|
|
34
|
+
detail,
|
|
35
|
+
options: [
|
|
36
|
+
{ id: "start", label: "Start" },
|
|
37
|
+
{ id: "no", label: "Don't start" },
|
|
38
|
+
],
|
|
39
|
+
})
|
|
40
|
+
return choice === "start" ? { go: true } : { go: false, answer: { ran: false, reason: "The user chose not to start it." } }
|
|
41
|
+
}
|
|
42
|
+
if (args.confirmed === true) return { go: true }
|
|
43
|
+
return {
|
|
44
|
+
go: false,
|
|
45
|
+
answer: {
|
|
46
|
+
ran: false,
|
|
47
|
+
needs_confirmation: true,
|
|
48
|
+
what: `${title} ${detail}`,
|
|
49
|
+
next_step: "Tell the user this takes over the desktop's mouse and keyboard, and call again with confirmed: true only after they agree.",
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function stepSummary(r: DesktopResult) {
|
|
55
|
+
return (r.steps || []).map((s) => ({ index: s.index, op: s.op, ok: s.ok, ...(s.error ? { error: clip(s.error, 300) } : {}), ...(s.healed_target ? { healed_to: s.healed_target } : {}) }))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DESKTOP_TOOLS: McpTool[] = [
|
|
59
|
+
{
|
|
60
|
+
name: "test_apps",
|
|
61
|
+
description:
|
|
62
|
+
"The test environments (env.yaml) and the apps in one: web apps with their URL, Windows desktop apps, and mobile apps, each with what it is for, plus the environment's variables and the secrets its workflows may type, and which apps can run on this machine. Call it first for any request that names an app or an environment, and to plan a scenario that moves between apps (e.g. order on the web shop, invoice in the desktop billing app): pick the apps by their descriptions, never invent a URL or path.",
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: { env: { type: "string", description: "An environment by name (default: the active one)" } },
|
|
66
|
+
},
|
|
67
|
+
async run(args, ctx) {
|
|
68
|
+
const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
|
|
69
|
+
const env = activeEnvironment(cfg)
|
|
70
|
+
const windows = process.platform === "win32"
|
|
71
|
+
const doc = windows ? desktopDoctor() : undefined
|
|
72
|
+
const apps = env
|
|
73
|
+
? Object.values(env.apps).map((a) => ({
|
|
74
|
+
name: a.name,
|
|
75
|
+
kind: a.kind,
|
|
76
|
+
...(a.description ? { description: a.description } : {}),
|
|
77
|
+
...(a.url ? { url: a.url } : {}),
|
|
78
|
+
...(a.path ? { path: a.path } : {}),
|
|
79
|
+
...(a.platform ? { platform: a.platform, app_id: a.appId } : {}),
|
|
80
|
+
...(a.login ? { login: a.login } : {}),
|
|
81
|
+
usable_here: a.kind === "desktop" ? windows : true,
|
|
82
|
+
...(a.kind === "desktop" && !windows ? { why_not: NOT_WINDOWS } : {}),
|
|
83
|
+
tool:
|
|
84
|
+
a.kind === "web"
|
|
85
|
+
? "browser_run with app"
|
|
86
|
+
: a.kind === "desktop"
|
|
87
|
+
? "desktop_explore / desktop_record / desktop_replay with app"
|
|
88
|
+
: a.kind === "mobile"
|
|
89
|
+
? "mobile_explore / mobile_replay with app (Appium or Maestro); mobile_maestro for the team's Maestro flows"
|
|
90
|
+
: "API tests (run_api_scenarios, run_api_contracts)",
|
|
91
|
+
}))
|
|
92
|
+
: []
|
|
93
|
+
// desktop.apps in forgeqe.yaml itself, outside any environment.
|
|
94
|
+
const extraDesktop = Object.keys(cfg.desktop.apps).filter((n) => !env?.apps[n])
|
|
95
|
+
return {
|
|
96
|
+
environment: env ? { name: env.name, ...(env.description ? { description: env.description } : {}) } : null,
|
|
97
|
+
environments: cfg.environments ? Object.values(cfg.environments.all).map((e) => ({ name: e.name, ...(e.description ? { description: e.description } : {}), active: e.name === env?.name })) : [],
|
|
98
|
+
apps,
|
|
99
|
+
...(extraDesktop.length ? { other_desktop_apps: extraDesktop } : {}),
|
|
100
|
+
variables: env?.variables || {},
|
|
101
|
+
secrets: cfg.desktop.secrets,
|
|
102
|
+
...(cfg.mobile.secrets.length ? { mobile_secrets: cfg.mobile.secrets } : {}),
|
|
103
|
+
refused_secrets: privateDesktopSecrets(cfg),
|
|
104
|
+
windows,
|
|
105
|
+
desktop_engine_ready: Boolean(doc?.ready),
|
|
106
|
+
desktop_explore_ready: Boolean(doc?.ufo),
|
|
107
|
+
...(!cfg.environments ? { next_step: "No environments are configured: tell the user to run forgeqe init (it writes env.yaml) and list their apps there." } : apps.length || extraDesktop.length ? {} : { next_step: `Environment ${env?.name || ""} has no apps: tell the user to add them to ${cfg.environments.file}.` }),
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "use_environment",
|
|
113
|
+
description:
|
|
114
|
+
"Switch the test environment (env.yaml) for the rest of this session: every later browser run, test case run, API test, desktop run, and the tests ForgeQE runs (FORGEQE_APP_*_URL) use it. Use when the user says to work in another environment (\"now in UAT\"); for a single call, pass env to that tool instead.",
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: { env: { type: "string", description: "The environment's name, from test_apps" } },
|
|
118
|
+
required: ["env"],
|
|
119
|
+
},
|
|
120
|
+
line: (args) => `environment ${String(args.env || "")}`,
|
|
121
|
+
async run(args, ctx) {
|
|
122
|
+
const name = String(args.env || "").trim()
|
|
123
|
+
if (!name) throw new Error("env is required.")
|
|
124
|
+
const switched = withEnvironment(ctx.config, name)
|
|
125
|
+
// The session shares this config object: later tool calls see the new environment.
|
|
126
|
+
Object.assign(ctx.config, switched)
|
|
127
|
+
exportEnvironment(ctx.config)
|
|
128
|
+
process.env.FORGEQE_ENV = name
|
|
129
|
+
ctx.emit?.({ type: "info", text: `Environment: ${name}`, ok: true })
|
|
130
|
+
return { environment: name, brief: environmentBrief(ctx.config, "agent") }
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "desktop_explore",
|
|
135
|
+
description:
|
|
136
|
+
"Carry out a goal in a Windows desktop app (from test_apps) with UFO² on this machine's desktop, and save it as a replayable workflow with checks of the outcome. Asks the user first. Returns the workflow, steps, checks, values shown at the end (for the next app of a scenario), and the report. Windows only; needs a vision model.",
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {
|
|
140
|
+
goal: { type: "string", description: "What to do in the app, in the user's words" },
|
|
141
|
+
app: { type: "string", description: "A desktop app's name, from test_apps" },
|
|
142
|
+
name: { type: "string", description: "A name for the workflow" },
|
|
143
|
+
env: { type: "string", description: "The test environment (default: the active one)" },
|
|
144
|
+
confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
|
|
145
|
+
},
|
|
146
|
+
required: ["goal", "app"],
|
|
147
|
+
},
|
|
148
|
+
line: (args) => `desktop explore ${String(args.app || "")}: ${clip(String(args.goal || ""), 80)}`,
|
|
149
|
+
async run(args, ctx) {
|
|
150
|
+
const notHere = onWindows()
|
|
151
|
+
if (notHere) return { ran: false, reason: notHere }
|
|
152
|
+
const goal = String(args.goal || "").trim()
|
|
153
|
+
const app = String(args.app || "").trim()
|
|
154
|
+
if (!goal || !app) throw new Error("goal and app are required.")
|
|
155
|
+
const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
|
|
156
|
+
checkDesktopConfig(cfg, true)
|
|
157
|
+
checkApp(cfg, app)
|
|
158
|
+
const problem = visionProblem(cfg.activeProfile, cfg.llm)
|
|
159
|
+
if (problem) return { ran: false, reason: problem }
|
|
160
|
+
const ok = await confirmed(ctx, args, `UFO² will control this desktop's mouse and keyboard in ${app} to: ${clip(goal, 200)}.`, "It acts in that app's windows only (no shell commands). Use a test machine and a test account.")
|
|
161
|
+
if (!ok.go) return ok.answer
|
|
162
|
+
const outDir = desktopOutDir(cfg, "explore")
|
|
163
|
+
const { result, report } = await exploreGoal({ cfg, goal, app, name: args.name ? String(args.name) : undefined, outDir, signal: ctx.signal, onEvent: stepEvents(ctx), log: (t) => ctx.emit?.({ type: "info", text: t }) })
|
|
164
|
+
return {
|
|
165
|
+
ran: true,
|
|
166
|
+
ok: result.ok,
|
|
167
|
+
workflow: result.workflow || undefined,
|
|
168
|
+
recorded_steps: result.recorded ?? 0,
|
|
169
|
+
checks: result.checks ?? 0,
|
|
170
|
+
values: result.values && Object.keys(result.values).length ? result.values : undefined,
|
|
171
|
+
not_recorded: result.skipped?.length ? result.skipped : undefined,
|
|
172
|
+
refused: result.refused?.length ? result.refused.map((r) => r.action) : undefined,
|
|
173
|
+
ufo_judged_done: result.ufo_complete ?? undefined,
|
|
174
|
+
error: result.error || undefined,
|
|
175
|
+
report,
|
|
176
|
+
next_step: result.workflow
|
|
177
|
+
? `Offer desktop_replay with workflow ${result.workflow} to check it runs without a model.${(result.checks ?? 0) === 0 ? " It has no checks of the outcome: say so, and suggest adding expect steps." : ""}`
|
|
178
|
+
: "Say what UFO did and did not do; suggest a clearer goal or checking the app's controls with Inspect.exe.",
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "desktop_replay",
|
|
184
|
+
description:
|
|
185
|
+
"Replay a desktop workflow in its Windows app with no model, checking its expect steps. A moved control is healed with one model call and reported, never written. Asks the user first. Returns each step's result, heals, and the report. Windows only.",
|
|
186
|
+
inputSchema: {
|
|
187
|
+
type: "object",
|
|
188
|
+
properties: {
|
|
189
|
+
workflow: { type: "string", description: "The workflow file, in the working or reports folder" },
|
|
190
|
+
variables: { type: "object", additionalProperties: { type: "string" }, description: "Values for its {{name}} variables" },
|
|
191
|
+
heal: { type: "boolean", description: "Heal a moved control (default: desktop.heal)" },
|
|
192
|
+
env: { type: "string", description: "The test environment (default: the active one)" },
|
|
193
|
+
confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
|
|
194
|
+
},
|
|
195
|
+
required: ["workflow"],
|
|
196
|
+
},
|
|
197
|
+
line: (args) => `desktop replay ${String(args.workflow || "")}`,
|
|
198
|
+
async run(args, ctx) {
|
|
199
|
+
const notHere = onWindows()
|
|
200
|
+
if (notHere) return { ran: false, reason: notHere }
|
|
201
|
+
const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
|
|
202
|
+
checkDesktopConfig(cfg, false)
|
|
203
|
+
const workflow = allowedWorkflow(String(args.workflow || ""), [ctx.cwd, cfg.export.outDir])
|
|
204
|
+
const variables = checkVariables(args.variables)
|
|
205
|
+
const ok = await confirmed(ctx, args, `ForgeQE will replay ${workflow} on this desktop's mouse and keyboard.`, "It starts the workflow's app from forgeqe.yaml and acts in its windows only.")
|
|
206
|
+
if (!ok.go) return ok.answer
|
|
207
|
+
const heal = (args.heal === undefined ? cfg.desktop.heal : args.heal === true) && hasModel(cfg)
|
|
208
|
+
const outDir = desktopOutDir(cfg, "replay")
|
|
209
|
+
const { result, report } = await replayWorkflow({ cfg, workflow, variables, heal, outDir, signal: ctx.signal, onEvent: stepEvents(ctx), log: (t) => ctx.emit?.({ type: "info", text: t }) })
|
|
210
|
+
return {
|
|
211
|
+
ran: true,
|
|
212
|
+
ok: result.ok,
|
|
213
|
+
stopped: result.stopped || undefined,
|
|
214
|
+
steps: stepSummary(result),
|
|
215
|
+
total: result.total,
|
|
216
|
+
heals: result.heals?.length ? result.heals : undefined,
|
|
217
|
+
error: result.error || undefined,
|
|
218
|
+
report,
|
|
219
|
+
next_step: result.heals?.length
|
|
220
|
+
? `Tell the user which locators were healed; they can write them into the workflow with: forgeqe desktop replay "${workflow}" --accept-heals`
|
|
221
|
+
: result.ok
|
|
222
|
+
? undefined
|
|
223
|
+
: "Say which step failed and why, from its error and the report's screenshot.",
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "desktop_record",
|
|
229
|
+
description:
|
|
230
|
+
"Record a desktop workflow by hand, with no model: ForgeQE starts the Windows app and saves the user's clicks, typing and keys as workflow.yaml. Ctrl+Shift+F11 over a control adds a check of its text; Ctrl+Shift+F12 finishes. Passwords are saved as {{secret:NAME}}. Asks the user first. Windows only.",
|
|
231
|
+
inputSchema: {
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: {
|
|
234
|
+
app: { type: "string", description: "A desktop app's name, from test_apps" },
|
|
235
|
+
name: { type: "string", description: "A name for the workflow" },
|
|
236
|
+
env: { type: "string", description: "The test environment (default: the active one)" },
|
|
237
|
+
confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
|
|
238
|
+
},
|
|
239
|
+
required: ["app"],
|
|
240
|
+
},
|
|
241
|
+
line: (args) => `desktop record ${String(args.app || "")}`,
|
|
242
|
+
async run(args, ctx) {
|
|
243
|
+
const notHere = onWindows()
|
|
244
|
+
if (notHere) return { ran: false, reason: notHere }
|
|
245
|
+
const app = String(args.app || "").trim()
|
|
246
|
+
if (!app) throw new Error("app is required.")
|
|
247
|
+
const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
|
|
248
|
+
checkDesktopConfig(cfg, false)
|
|
249
|
+
checkApp(cfg, app)
|
|
250
|
+
const ok = await confirmed(
|
|
251
|
+
ctx,
|
|
252
|
+
args,
|
|
253
|
+
`ForgeQE will start ${app} and record what you do in it.`,
|
|
254
|
+
"Do the steps yourself. Ctrl+Shift+F11 over a control adds a check of what it shows; Ctrl+Shift+F12 or closing the app finishes. Only clicks and keys in the app's own windows are recorded; password fields are saved as secret names, never as what you type.",
|
|
255
|
+
)
|
|
256
|
+
if (!ok.go) return ok.answer
|
|
257
|
+
const outDir = desktopOutDir(cfg, "record")
|
|
258
|
+
const onEvent = (e: DesktopEvent) => {
|
|
259
|
+
if (e.type === "recorded") ctx.emit?.({ type: "info", text: `● ${e.index} ${e.op}${e.keys ? ` ${e.keys}` : ""}${e.target ? ` ${clip(String(e.target.name || e.target.automation_id || ""), 60)}` : ""}` })
|
|
260
|
+
else stepEvents(ctx)(e)
|
|
261
|
+
}
|
|
262
|
+
const { result, report } = await recordByHand({ cfg, app, name: args.name ? String(args.name) : undefined, outDir, signal: ctx.signal, onEvent, log: (t) => ctx.emit?.({ type: "info", text: t }) })
|
|
263
|
+
const notes = recordingNotes(cfg, result)
|
|
264
|
+
return {
|
|
265
|
+
ran: true,
|
|
266
|
+
ok: result.ok,
|
|
267
|
+
workflow: result.workflow || undefined,
|
|
268
|
+
recorded_steps: result.recorded ?? 0,
|
|
269
|
+
checks: result.checks ?? 0,
|
|
270
|
+
steps: (result.steps || []).map((s) => ({ index: s.index, op: s.op, ...(s.target ? { target: s.target } : {}) })),
|
|
271
|
+
not_recorded: result.skipped?.length ? result.skipped : undefined,
|
|
272
|
+
secrets: result.secrets?.length ? result.secrets : undefined,
|
|
273
|
+
stopped_by: result.stopped_by || undefined,
|
|
274
|
+
notes: notes.length ? notes : undefined,
|
|
275
|
+
error: result.error || undefined,
|
|
276
|
+
report,
|
|
277
|
+
next_step: result.workflow ? `Show the steps, pass on the notes, and offer desktop_replay with workflow ${result.workflow}.` : "Say nothing was recorded, and why (the error), and offer to start again.",
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: "desktop_edit_workflow",
|
|
283
|
+
description:
|
|
284
|
+
"Change a desktop or mobile workflow after a failed replay or on request: timeout, retries, a wait_for step, a dialog to dismiss, a healed target, a text, steps, variables. Step numbers are the replay report's. Validated; the user sees the diff and is asked before it is written.",
|
|
285
|
+
inputSchema: {
|
|
286
|
+
type: "object",
|
|
287
|
+
properties: {
|
|
288
|
+
workflow: { type: "string", description: "The workflow file, in the working or reports folder" },
|
|
289
|
+
reason: { type: "string", description: "Why, in one sentence for the user" },
|
|
290
|
+
changes: {
|
|
291
|
+
type: "array",
|
|
292
|
+
description:
|
|
293
|
+
'In order: {op:"set"|"unset", field:"timeout"|"retries", value?, step?} (no step: the workflow); {op:"set_step", step, field, value} (field: target, text, value, keys, seconds, direction, gone, image, tolerance, ignore, note, timeout, retries); {op:"add_dismiss", target}; {op:"insert_step", before|after, step}; {op:"remove_step", step}; {op:"set_variable", name, value}.',
|
|
294
|
+
items: { type: "object" },
|
|
295
|
+
},
|
|
296
|
+
confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed to the diff" },
|
|
297
|
+
},
|
|
298
|
+
required: ["workflow", "changes"],
|
|
299
|
+
},
|
|
300
|
+
line: (args) => `desktop edit ${String(args.workflow || "")}`,
|
|
301
|
+
async run(args, ctx) {
|
|
302
|
+
const cfg = ctx.config
|
|
303
|
+
const workflow = allowedWorkflow(String(args.workflow || ""), [ctx.cwd, cfg.export.outDir])
|
|
304
|
+
let edit
|
|
305
|
+
try {
|
|
306
|
+
edit = await proposeEdit(workflow, args.changes, ctx.validateDesktopWorkflow || engineValidate)
|
|
307
|
+
} catch (e) {
|
|
308
|
+
return { written: false, error: (e as Error).message, next_step: "Fix the changes (the error says which) and call again; nothing was written." }
|
|
309
|
+
}
|
|
310
|
+
if (edit.before === edit.after) return { written: false, reason: "The changes leave the workflow as it is." }
|
|
311
|
+
const reason = clip(String(args.reason || ""), 300)
|
|
312
|
+
const shown = { workflow, changes: edit.summary, diff: edit.diff }
|
|
313
|
+
if (ctx.ask) {
|
|
314
|
+
const choice = await ctx.ask({
|
|
315
|
+
title: `Change ${path.basename(workflow)}?`,
|
|
316
|
+
detail: reason || undefined,
|
|
317
|
+
lines: [...edit.summary.map((l) => `• ${l}`), "", ...edit.diff.slice(0, 80)],
|
|
318
|
+
options: [
|
|
319
|
+
{ id: "write", label: "Yes, write the change" },
|
|
320
|
+
{ id: "no", label: "No, leave the workflow as it is" },
|
|
321
|
+
],
|
|
322
|
+
})
|
|
323
|
+
if (choice !== "write") return { ...shown, written: false, reason: "The user chose not to change the workflow." }
|
|
324
|
+
} else if (args.confirmed !== true) {
|
|
325
|
+
return { ...shown, written: false, needs_confirmation: true, next_step: "Show the user this diff, and call again with the same changes and confirmed: true only after they agree." }
|
|
326
|
+
}
|
|
327
|
+
const backupDir = path.join(cfg.export.outDir, "desktop-edits")
|
|
328
|
+
mkdirSync(backupDir, { recursive: true })
|
|
329
|
+
const backup = writeEdit(workflow, edit, backupDir)
|
|
330
|
+
ctx.emit?.({ type: "info", text: `Updated ${workflow}`, ok: true })
|
|
331
|
+
return { ...shown, written: true, backup, next_step: `Offer desktop_replay with workflow ${workflow} to check the fix.` }
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { allowedWorkflow, checkVariables, visionProblem } from "../desktop/run.ts"
|
|
2
|
+
import { withEnvironment } from "../environments.ts"
|
|
3
|
+
import { checkMobileApp, checkMobileConfig, exploreMobile, hasModel, maestroFlowFiles, mobileOutDir, replayMobile, runMaestroFlows } from "../mobile/run.ts"
|
|
4
|
+
import { confirmed, stepEvents, stepSummary } from "./desktoptools.ts"
|
|
5
|
+
import type { McpTool } from "./tools.ts"
|
|
6
|
+
|
|
7
|
+
/** Android and iOS apps (docs/MOBILE.md): explore a goal through Appium, replay a workflow on Appium or Maestro, run the team's Maestro flows. */
|
|
8
|
+
|
|
9
|
+
const clip = (s: string | undefined, n: number) => {
|
|
10
|
+
const t = (s || "").trim()
|
|
11
|
+
return t.length <= n ? t : `${t.slice(0, n - 1)}…`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const MOBILE_TOOLS: McpTool[] = [
|
|
15
|
+
{
|
|
16
|
+
name: "mobile_explore",
|
|
17
|
+
description:
|
|
18
|
+
"Carry out a goal in an Android or iOS app (from test_apps) through Appium, and save it as a replayable workflow with checks of the outcome. Asks the user first. Returns the workflow, steps, checks, values shown at the end (for the next app of a scenario), and the report. Needs Appium and a vision model.",
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
goal: { type: "string", description: "What to do in the app, in the user's words" },
|
|
23
|
+
app: { type: "string", description: "A mobile app's name, from test_apps" },
|
|
24
|
+
name: { type: "string", description: "A name for the workflow" },
|
|
25
|
+
env: { type: "string", description: "The test environment (default: the active one)" },
|
|
26
|
+
confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
|
|
27
|
+
},
|
|
28
|
+
required: ["goal", "app"],
|
|
29
|
+
},
|
|
30
|
+
line: (args) => `mobile explore ${String(args.app || "")}: ${clip(String(args.goal || ""), 80)}`,
|
|
31
|
+
async run(args, ctx) {
|
|
32
|
+
const goal = String(args.goal || "").trim()
|
|
33
|
+
const app = String(args.app || "").trim()
|
|
34
|
+
if (!goal || !app) throw new Error("goal and app are required.")
|
|
35
|
+
const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
|
|
36
|
+
checkMobileConfig(cfg)
|
|
37
|
+
checkMobileApp(cfg, app)
|
|
38
|
+
if (!hasModel(cfg)) return { ran: false, reason: "No model is set up in llms.json; explore needs one. Replay and Maestro flows need none." }
|
|
39
|
+
const problem = visionProblem(cfg.activeProfile, cfg.llm)
|
|
40
|
+
if (problem) return { ran: false, reason: problem.replace("the desktop agent", "the mobile agent") }
|
|
41
|
+
const ok = await confirmed(ctx, args, `The mobile agent will operate ${app} on the device to: ${clip(goal, 200)}.`, `Through Appium at ${cfg.mobile.appium.url}. Use a test device and a test account.`)
|
|
42
|
+
if (!ok.go) return ok.answer
|
|
43
|
+
const outDir = mobileOutDir(cfg, "explore")
|
|
44
|
+
const { result, report } = await exploreMobile({ cfg, goal, app, name: args.name ? String(args.name) : undefined, outDir, signal: ctx.signal, onEvent: stepEvents(ctx), log: (t) => ctx.emit?.({ type: "info", text: t }) })
|
|
45
|
+
return {
|
|
46
|
+
ran: true,
|
|
47
|
+
ok: result.ok,
|
|
48
|
+
workflow: result.workflow || undefined,
|
|
49
|
+
recorded_steps: result.recorded ?? 0,
|
|
50
|
+
checks: result.checks ?? 0,
|
|
51
|
+
values: result.values && Object.keys(result.values).length ? result.values : undefined,
|
|
52
|
+
error: result.error || undefined,
|
|
53
|
+
report,
|
|
54
|
+
next_step: result.workflow
|
|
55
|
+
? `Offer mobile_replay with workflow ${result.workflow} (driver appium or maestro).${(result.checks ?? 0) === 0 ? " It has no checks of the outcome: say so." : ""}`
|
|
56
|
+
: "Say what was and was not done; suggest a clearer goal.",
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "mobile_replay",
|
|
62
|
+
description:
|
|
63
|
+
"Replay a mobile workflow (forgeqe-mobile-v1) with no model: on Appium step by step (a moved control is healed and reported, never written), or on Maestro. Asks the user first. Returns each step's result and the report.",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
workflow: { type: "string", description: "The workflow file, in the working or reports folder" },
|
|
68
|
+
variables: { type: "object", additionalProperties: { type: "string" }, description: "Values for its {{name}} variables" },
|
|
69
|
+
driver: { type: "string", enum: ["appium", "maestro"], description: "Default: mobile.driver" },
|
|
70
|
+
heal: { type: "boolean", description: "Appium: heal a moved control (default: mobile.heal)" },
|
|
71
|
+
env: { type: "string", description: "The test environment (default: the active one)" },
|
|
72
|
+
confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
|
|
73
|
+
},
|
|
74
|
+
required: ["workflow"],
|
|
75
|
+
},
|
|
76
|
+
line: (args) => `mobile replay ${String(args.workflow || "")}`,
|
|
77
|
+
async run(args, ctx) {
|
|
78
|
+
const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
|
|
79
|
+
checkMobileConfig(cfg)
|
|
80
|
+
const workflow = allowedWorkflow(String(args.workflow || ""), [ctx.cwd, cfg.export.outDir])
|
|
81
|
+
const variables = checkVariables(args.variables)
|
|
82
|
+
const driver = args.driver === undefined ? cfg.mobile.driver : args.driver
|
|
83
|
+
if (driver !== "appium" && driver !== "maestro") throw new Error("driver must be appium or maestro.")
|
|
84
|
+
const ok = await confirmed(ctx, args, `ForgeQE will replay ${workflow} on the device with ${driver}.`, "It starts the workflow's app from the environment or forgeqe.yaml mobile.apps and acts in it only.")
|
|
85
|
+
if (!ok.go) return ok.answer
|
|
86
|
+
const heal = args.heal === undefined ? cfg.mobile.heal : args.heal === true
|
|
87
|
+
const outDir = mobileOutDir(cfg, "replay")
|
|
88
|
+
const { result, report } = await replayMobile({ cfg, workflow, variables, heal, driver, outDir, signal: ctx.signal, onEvent: stepEvents(ctx), log: (t) => ctx.emit?.({ type: "info", text: t }) })
|
|
89
|
+
return {
|
|
90
|
+
ran: true,
|
|
91
|
+
ok: result.ok,
|
|
92
|
+
driver,
|
|
93
|
+
steps: stepSummary(result),
|
|
94
|
+
total: result.total,
|
|
95
|
+
heals: result.heals?.length ? result.heals : undefined,
|
|
96
|
+
error: result.error || undefined,
|
|
97
|
+
report,
|
|
98
|
+
next_step: result.heals?.length
|
|
99
|
+
? "Tell the user which locators were healed; desktop_edit_workflow (set_step target) writes them into the workflow when they agree."
|
|
100
|
+
: result.ok
|
|
101
|
+
? undefined
|
|
102
|
+
: "Say which step failed and why. When the app was slow or a dialog was in the way, fix the workflow with desktop_edit_workflow (it edits mobile workflows too).",
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "mobile_maestro",
|
|
108
|
+
description:
|
|
109
|
+
"Run the team's own Maestro flows (files or folders in the working folder). Each is checked first: only UI commands pass (no scripts, JavaScript, AI commands or file writes). Asks the user first. Returns each flow's result.",
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties: {
|
|
113
|
+
flows: { type: "array", items: { type: "string" }, description: "Flow files or folders" },
|
|
114
|
+
env: { type: "string", description: "The test environment (default: the active one)" },
|
|
115
|
+
confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
|
|
116
|
+
},
|
|
117
|
+
required: ["flows"],
|
|
118
|
+
},
|
|
119
|
+
line: (args) => `maestro ${Array.isArray(args.flows) ? args.flows.join(" ") : ""}`,
|
|
120
|
+
async run(args, ctx) {
|
|
121
|
+
const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
|
|
122
|
+
checkMobileConfig(cfg)
|
|
123
|
+
if (!Array.isArray(args.flows) || !args.flows.length || !args.flows.every((f: unknown) => typeof f === "string")) throw new Error("flows must be a list of files or folders.")
|
|
124
|
+
const flows = maestroFlowFiles(args.flows as string[], ctx.cwd)
|
|
125
|
+
const ok = await confirmed(ctx, args, `ForgeQE will run ${flows.length} Maestro flow${flows.length === 1 ? "" : "s"} on the device.`, "Each is checked against the allowlist first.")
|
|
126
|
+
if (!ok.go) return ok.answer
|
|
127
|
+
const outDir = mobileOutDir(cfg, "maestro")
|
|
128
|
+
const { result, report } = await runMaestroFlows({ cfg, flows, root: ctx.cwd, outDir, signal: ctx.signal, onEvent: stepEvents(ctx), log: (t) => ctx.emit?.({ type: "info", text: t }) })
|
|
129
|
+
return { ran: true, ok: result.ok, flows: result.flows || [], checked: result.checked?.length, error: result.error || undefined, report }
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
]
|
package/src/mcp/reporttools.ts
CHANGED
|
@@ -84,9 +84,24 @@ ${body}
|
|
|
84
84
|
`
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/** The summary as HTML, with raw HTML in it shown as text so a report can never run script. */
|
|
87
|
+
/** The summary as HTML, with raw HTML in it shown as text and only web links kept, so a report can never run script. */
|
|
88
88
|
export function summaryHtml(markdown: string): string {
|
|
89
|
-
|
|
89
|
+
const html = Bun.markdown.html(markdown.replace(/<(?=[A-Za-z/!?])/g, "<"))
|
|
90
|
+
return html.replace(/\s(href|src)="([^"]*)"/g, (whole, _attr: string, value: string) => (linkAllowed(value) ? whole : ""))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const codePoint = (n: number) => (n <= 0x10ffff ? String.fromCodePoint(n) : "")
|
|
94
|
+
|
|
95
|
+
/** A link a report may keep: http(s), mailto, or one with no scheme (an anchor or a relative path). */
|
|
96
|
+
function linkAllowed(attr: string): boolean {
|
|
97
|
+
const url = attr
|
|
98
|
+
.replace(/&#x([0-9a-f]+);?/gi, (_, h: string) => codePoint(parseInt(h, 16)))
|
|
99
|
+
.replace(/&#(\d+);?/g, (_, d: string) => codePoint(Number(d)))
|
|
100
|
+
.replace(/"/g, '"')
|
|
101
|
+
.replace(/&/g, "&")
|
|
102
|
+
.replace(/[\u0000-\u0020\u007f]/g, "")
|
|
103
|
+
const scheme = /^([^/?#]*?):/.exec(url)?.[1]
|
|
104
|
+
return !scheme || /^(?:https?|mailto)$/i.test(scheme)
|
|
90
105
|
}
|
|
91
106
|
|
|
92
107
|
const MAX_ROWS = 5000
|
package/src/mcp/repotests.ts
CHANGED
|
@@ -5,6 +5,10 @@ import { privateEnvNames } from "../childEnv.ts"
|
|
|
5
5
|
import { findSprint, listSprints, recentSprints, sprintLabel, type Sprint } from "../cases/sprints.ts"
|
|
6
6
|
import { findTests, loadSelection, resultsCard, runSelection, selectionCard, type TestRange } from "../code/find.ts"
|
|
7
7
|
import { CAUSE_TEXT, flakyReport, matchTest, RELEASE_AFTER, setQuarantine } from "../code/history.ts"
|
|
8
|
+
import { containersOf, startOrder } from "../containers/config.ts"
|
|
9
|
+
import { ensureServices, findRuntime } from "../containers/run.ts"
|
|
10
|
+
import { containerTestRunner } from "../containers/tests.ts"
|
|
11
|
+
import { containerExec } from "./containertools.ts"
|
|
8
12
|
import type { McpTool, ToolContext } from "./tools.ts"
|
|
9
13
|
|
|
10
14
|
const clip = (s: string | undefined, n: number) => {
|
|
@@ -144,6 +148,7 @@ export const REPO_TEST_TOOLS: McpTool[] = [
|
|
|
144
148
|
selection_id: { type: "string", description: "From find_tests" },
|
|
145
149
|
confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed to run this list" },
|
|
146
150
|
timeout_seconds: { type: "number", description: "Stop the run after this many seconds (default: code.test_timeout_seconds)" },
|
|
151
|
+
in_container: { type: "boolean", description: "Run them in containers.tests' image (Docker or Podman), with its services started first. Default: true when forgeqe.yaml sets containers.tests" },
|
|
147
152
|
},
|
|
148
153
|
required: ["selection_id"],
|
|
149
154
|
},
|
|
@@ -151,10 +156,16 @@ export const REPO_TEST_TOOLS: McpTool[] = [
|
|
|
151
156
|
const sel = loadSelection(ctx.config, String(args.selection_id))
|
|
152
157
|
const n = sel.tests.length
|
|
153
158
|
if (!n) return { ran: false, reason: "The selection has no tests." }
|
|
159
|
+
const tc = containersOf(ctx.config).tests
|
|
160
|
+
const inContainer = args.in_container === undefined ? Boolean(tc) : args.in_container === true
|
|
161
|
+
if (inContainer && !tc) return { ran: false, reason: "containers.tests is not set in forgeqe.yaml: it names the image the tests run in (docs/CONTAINERS.md)." }
|
|
162
|
+
const runtime = inContainer ? findRuntime(ctx.config) : null
|
|
163
|
+
if (inContainer && !runtime) return { ran: false, reason: "Docker or Podman is not on PATH, so the tests cannot run in a container. Run them on this machine with in_container: false, or install one." }
|
|
164
|
+
const where = inContainer && tc ? ` in a container (${tc.image}${tc.services.length ? `, with ${tc.services.join(", ")}` : ""})` : ""
|
|
154
165
|
const what = `${n} test${n === 1 ? "" : "s"}`
|
|
155
166
|
if (ctx.ask) {
|
|
156
167
|
const choice = await ctx.ask({
|
|
157
|
-
title: `Run ${what}?`,
|
|
168
|
+
title: `Run ${what}${where}?`,
|
|
158
169
|
detail: sel.runCommand,
|
|
159
170
|
lines: [`Selected for "${sel.request}":`, ...sel.tests.slice(0, 20).map((t) => ` • ${t.name}`), ...(n > 20 ? [` and ${n - 20} more`] : [])],
|
|
160
171
|
options: [
|
|
@@ -169,12 +180,20 @@ export const REPO_TEST_TOOLS: McpTool[] = [
|
|
|
169
180
|
needs_confirmation: true,
|
|
170
181
|
tests: sel.tests.map((t) => t.name),
|
|
171
182
|
run_command: sel.runCommand,
|
|
183
|
+
...(where ? { where: where.trim() } : {}),
|
|
172
184
|
next_step: "Show the user this list and ask whether to run it; call run_tests again with confirmed: true only after they say yes.",
|
|
173
185
|
}
|
|
174
186
|
}
|
|
175
|
-
ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text: `ForgeQE runs ${what}: ${sel.runCommand}` } })
|
|
187
|
+
ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text: `ForgeQE runs ${what}${where}: ${sel.runCommand}` } })
|
|
176
188
|
const timeout = Number(args.timeout_seconds) > 0 ? Number(args.timeout_seconds) : ctx.config.code.testTimeoutSeconds
|
|
177
|
-
|
|
189
|
+
let runTest = ctx.runTest
|
|
190
|
+
if (inContainer && tc && runtime) {
|
|
191
|
+
const rt = { cfg: ctx.config, runtime, exec: containerExec(), ...(ctx.signal ? { signal: ctx.signal } : {}), log: (text: string) => ctx.emit?.({ type: "info" as const, text }) }
|
|
192
|
+
const svc = await ensureServices(rt, startOrder(containersOf(ctx.config).services, tc.services))
|
|
193
|
+
if (svc.failed) return { ran: false, reason: `${svc.failed.service} did not start: ${svc.failed.error}`, logs: svc.failed.logs }
|
|
194
|
+
runTest = containerTestRunner(rt, sel.repo, [sel.dir])
|
|
195
|
+
}
|
|
196
|
+
const r = await runSelection({ selection: sel, timeoutSeconds: timeout, signal: ctx.signal, runTest, hide: privateEnvNames(ctx.config), flaky: ctx.config.flaky })
|
|
178
197
|
ctx.emit?.({ type: "card", card: resultsCard(r, ctx.cwd) })
|
|
179
198
|
const failed = r.tests.filter((t) => t.status === "failed")
|
|
180
199
|
return {
|
package/src/mcp/routing.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { CAPABILITIES, type Capability } from "../capabilities/registry.ts"
|
|
2
2
|
import { prompt } from "../prompts.ts"
|
|
3
|
+
import type { CapabilityScope } from "./scope.ts"
|
|
3
4
|
import type { McpTool } from "./tools.ts"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* What ForgeQE's agent carries on every model call, and what it gets only when a task needs it.
|
|
7
8
|
*
|
|
8
|
-
* Every model call sends the system prompt and the tool definitions again.
|
|
9
|
-
*
|
|
10
|
-
* tool listed in one line cost an extra model call each time (measured: more tokens, not fewer).
|
|
11
|
-
*
|
|
9
|
+
* Every model call sends the system prompt and the tool definitions again. A tool the agent has stays listed in full:
|
|
10
|
+
* its description is how the agent chooses a tool and plans the task, and its arguments are needed for any call, so a
|
|
11
|
+
* tool listed in one line cost an extra model call each time (measured: more tokens, not fewer). Which tools a turn
|
|
12
|
+
* has is the scope's job (src/mcp/scope.ts): the core capabilities and the ones the request is about. The playbook is
|
|
13
|
+
* what moves within them: the sections of brain.md that a capability owns (`playbook`) leave the system prompt, an index of them
|
|
12
14
|
* stays, and the first result of one of the capability's tools in a session (or of a tool in `alsoAfter`) carries the
|
|
13
15
|
* section. The `playbook` tool reads one before that. A section no capability owns stays in the system prompt, so a
|
|
14
16
|
* section renamed in a team's own copy of brain.md is never lost.
|
|
@@ -84,7 +86,7 @@ export const brainParts = (): BrainParts => splitBrain(prompt("brain"))
|
|
|
84
86
|
* Routing for one agent session: which playbooks it was given, and what goes with a result. The playbook text is read
|
|
85
87
|
* once, when the session starts, like the system prompt.
|
|
86
88
|
*/
|
|
87
|
-
export function createRouter(tools: McpTool[], opts: { caps?: Capability[]; brain?: string } = {}) {
|
|
89
|
+
export function createRouter(tools: McpTool[], opts: { caps?: Capability[]; brain?: string; scope?: CapabilityScope } = {}) {
|
|
88
90
|
const caps = opts.caps || CAPABILITIES
|
|
89
91
|
const parts = splitBrain(opts.brain ?? prompt("brain"), caps)
|
|
90
92
|
const given = new Set<string>()
|
|
@@ -100,8 +102,11 @@ export function createRouter(tools: McpTool[], opts: { caps?: Capability[]; brai
|
|
|
100
102
|
|
|
101
103
|
return {
|
|
102
104
|
parts,
|
|
105
|
+
/** Which tools each turn of this session gets (src/mcp/scope.ts); absent when every tool is always given. */
|
|
106
|
+
scope: opts.scope,
|
|
103
107
|
/** What goes after an error of a tool: the playbooks of its capability this session has not had yet. */
|
|
104
108
|
after(name: string): string {
|
|
109
|
+
opts.scope?.used(name)
|
|
105
110
|
return block(give(playbooksFor(name)))
|
|
106
111
|
},
|
|
107
112
|
/**
|
|
@@ -109,6 +114,7 @@ export function createRouter(tools: McpTool[], opts: { caps?: Capability[]; brai
|
|
|
109
114
|
* whatever reads it; appended to any other text.
|
|
110
115
|
*/
|
|
111
116
|
attach(name: string, text: string): string {
|
|
117
|
+
opts.scope?.used(name)
|
|
112
118
|
const books = give(playbooksFor(name))
|
|
113
119
|
if (!books.length) return text
|
|
114
120
|
const playbook = `Obey it for this task:\n\n${books.map((b) => b.text).join("\n\n")}`
|