@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,155 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { DesktopResult, DesktopStep } from "./bridge.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* report.html for a desktop run: every value from run.json is escaped, since step errors, control names and goals come
|
|
8
|
+
* from the app under test and the model. Images are only the engine's own files: step screenshots (steps/NNN.png) and
|
|
9
|
+
* expect_image's copies (images/NNN-baseline|actual|diff.png).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function escapeHtml(value: unknown): string {
|
|
13
|
+
return String(value ?? "")
|
|
14
|
+
.replace(/&/g, "&")
|
|
15
|
+
.replace(/</g, "<")
|
|
16
|
+
.replace(/>/g, ">")
|
|
17
|
+
.replace(/"/g, """)
|
|
18
|
+
.replace(/'/g, "'")
|
|
19
|
+
.replace(/\u2028/g, "
")
|
|
20
|
+
.replace(/\u2029/g, "
")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SCREENSHOT = /^steps\/\d{3}\.png$/
|
|
24
|
+
const STEP_IMAGE = /^images\/\d{3}-(baseline|actual|diff)\.png$/
|
|
25
|
+
|
|
26
|
+
/** A step screenshot's relative path, or null for anything that is not one the engine wrote. */
|
|
27
|
+
export function screenshotPath(value: unknown): string | null {
|
|
28
|
+
return typeof value === "string" && SCREENSHOT.test(value) ? value : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** An expect_image file's relative path (images/NNN-baseline|actual|diff.png), or null. */
|
|
32
|
+
export function stepImagePath(value: unknown): string | null {
|
|
33
|
+
return typeof value === "string" && STEP_IMAGE.test(value) ? value : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function imageCell(s: DesktopStep): string {
|
|
37
|
+
const im = s.images
|
|
38
|
+
if (!im) return ""
|
|
39
|
+
const pics = (["baseline", "actual", "diff"] as const)
|
|
40
|
+
.map((k) => {
|
|
41
|
+
const src = stepImagePath(im[k])
|
|
42
|
+
return src ? `<figure><a href="${src}"><img src="${src}" alt="${k} of step ${escapeHtml(s.index)}" loading="lazy"></a><figcaption>${k}</figcaption></figure>` : ""
|
|
43
|
+
})
|
|
44
|
+
.join("")
|
|
45
|
+
const share = typeof im.differs === "number" ? `<div class="meta">${escapeHtml((im.differs * 100).toFixed(2))}% of pixels differ</div>` : ""
|
|
46
|
+
return `<div class="images">${pics}</div>${share}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function target(t: Record<string, unknown> | null | undefined): string {
|
|
50
|
+
if (!t) return ""
|
|
51
|
+
return Object.entries(t)
|
|
52
|
+
.filter(([k]) => k !== "path")
|
|
53
|
+
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
54
|
+
.join(", ")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stepRow(s: DesktopStep): string {
|
|
58
|
+
const shot = screenshotPath(s.screenshot)
|
|
59
|
+
const healed = s.healed_target ? `<div class="heal">healed to ${escapeHtml(target(s.healed_target))}</div>` : ""
|
|
60
|
+
const tries = typeof s.attempts === "number" && s.attempts > 1 ? `<div class="heal">took ${escapeHtml(s.attempts)} tries</div>` : ""
|
|
61
|
+
const dismissed = (s.dismissed || []).length ? `<div class="heal">closed in the way: ${escapeHtml((s.dismissed || []).join(", "))}</div>` : ""
|
|
62
|
+
const note = s.note ? `<div class="meta">${escapeHtml(s.note)}</div>` : ""
|
|
63
|
+
return `<tr class="${s.ok ? "ok" : "fail"}">
|
|
64
|
+
<td class="n">${escapeHtml(s.index)}</td>
|
|
65
|
+
<td><span class="badge ${s.ok ? "ok" : "fail"}">${s.ok ? "passed" : "failed"}</span></td>
|
|
66
|
+
<td><code>${escapeHtml(s.op)}</code></td>
|
|
67
|
+
<td>${escapeHtml(target(s.target))}${healed}${tries}${dismissed}${note}${s.error ? `<div class="err">${escapeHtml(s.error)}</div>` : ""}${imageCell(s)}</td>
|
|
68
|
+
<td class="n">${escapeHtml(s.ms)} ms</td>
|
|
69
|
+
<td>${shot ? `<a href="${shot}"><img src="${shot}" alt="Screen after step ${escapeHtml(s.index)}" loading="lazy"></a>` : ""}</td>
|
|
70
|
+
</tr>`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function flowsSection(r: DesktopResult): string {
|
|
74
|
+
if (!(r.flows || []).length) return ""
|
|
75
|
+
const rows = (r.flows || [])
|
|
76
|
+
.map((f) => `<tr class="${f.ok ? "ok" : "fail"}"><td><span class="badge ${f.ok ? "ok" : "fail"}">${f.ok ? "passed" : "failed"}</span></td><td>${escapeHtml(f.name)}${f.error ? `<div class="err">${escapeHtml(f.error)}</div>` : ""}</td></tr>`)
|
|
77
|
+
.join("")
|
|
78
|
+
return `<section><h2>Flows</h2><div class="table"><table><thead><tr><th>Result</th><th>Flow</th></tr></thead><tbody>${rows}</tbody></table></div></section>`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function desktopReportHtml(r: DesktopResult, meta: { model: string; when: string; environment?: string; kind?: "Desktop" | "Mobile" }): string {
|
|
82
|
+
const kind = meta.kind || "Desktop"
|
|
83
|
+
const title = `${kind} ${r.mode === "explore" ? "explore" : r.mode === "record" ? "recording" : r.mode === "maestro" ? "Maestro flows" : "replay"}`
|
|
84
|
+
const status = r.ok ? "passed" : r.stopped ? "stopped" : "failed"
|
|
85
|
+
const summary =
|
|
86
|
+
r.mode === "maestro"
|
|
87
|
+
? `${escapeHtml((r.flows || []).filter((f) => f.ok).length)} of ${escapeHtml((r.flows || []).length)} flows passed`
|
|
88
|
+
: r.mode === "record"
|
|
89
|
+
? `${escapeHtml(r.recorded ?? 0)} steps recorded by hand, ${escapeHtml(r.checks ?? 0)} checks${r.secrets?.length ? `, types the secrets ${escapeHtml(r.secrets.join(", "))}` : ""}`
|
|
90
|
+
: r.mode === "explore"
|
|
91
|
+
? `${escapeHtml(r.recorded ?? 0)} steps recorded, ${escapeHtml(r.checks ?? 0)} checks of the outcome${r.ufo_complete === false ? ", UFO judged the goal not done" : ""}`
|
|
92
|
+
: `${escapeHtml((r.steps || []).filter((s) => s.ok).length)} of ${escapeHtml(r.total ?? (r.steps || []).length)} steps passed`
|
|
93
|
+
const rows = (r.steps || []).map(stepRow).join("\n")
|
|
94
|
+
const heals = (r.heals || []).length
|
|
95
|
+
? `<section><h2>Healed locators</h2><p>Proposed for review; the workflow file is unchanged until you accept them.</p><ul>${(r.heals || [])
|
|
96
|
+
.map((h) => `<li>Step ${escapeHtml(h.index)}: ${escapeHtml(target(h.old))} → ${escapeHtml(target(h.new))}</li>`)
|
|
97
|
+
.join("")}</ul></section>`
|
|
98
|
+
: ""
|
|
99
|
+
const skipped = (r.skipped || []).length
|
|
100
|
+
? `<section><h2>Actions not recorded</h2><ul>${(r.skipped || []).map((s) => `<li>${escapeHtml(s.action)}: ${escapeHtml(s.reason)}</li>`).join("")}</ul></section>`
|
|
101
|
+
: ""
|
|
102
|
+
const refused = (r.refused || []).length
|
|
103
|
+
? `<section><h2>Refused</h2><p>Actions ForgeQE did not let UFO take.</p><ul>${(r.refused || []).map((s) => `<li>${escapeHtml(s.action)} ${escapeHtml(JSON.stringify(s.args || {}))}</li>`).join("")}</ul></section>`
|
|
104
|
+
: ""
|
|
105
|
+
return `<!doctype html>
|
|
106
|
+
<html lang="en">
|
|
107
|
+
<head>
|
|
108
|
+
<meta charset="utf-8">
|
|
109
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
110
|
+
<title>${escapeHtml(title)}: ${escapeHtml(r.name || r.goal || "")}</title>
|
|
111
|
+
<style>
|
|
112
|
+
:root { --bg: #f4f6f9; --surface: #ffffff; --line: #e3e8ee; --text: #1f2a44; --muted: #5f6b80; --brand: #0074bf;
|
|
113
|
+
--pass: #1b883c; --pass-bg: #e8f5ec; --fail: #c62828; --fail-bg: #fcecec; --warn: #c45c12; --radius: 14px; }
|
|
114
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #0f1424; --surface: #171e33; --line: #2a3350; --text: #e6eaf2; --muted: #9aa5bb;
|
|
115
|
+
--brand: #5bb6f0; --pass: #5fd07f; --pass-bg: #163322; --fail: #ff7b7b; --fail-bg: #3a1a1d; --warn: #f0a35e; } }
|
|
116
|
+
* { box-sizing: border-box; }
|
|
117
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
118
|
+
main { max-width: 1100px; margin: 0 auto; padding: 24px 16px; }
|
|
119
|
+
header, section { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 16px; }
|
|
120
|
+
h1 { font-size: 20px; margin: 0 0 4px; } h2 { font-size: 16px; margin: 0 0 8px; }
|
|
121
|
+
.meta { color: var(--muted); }
|
|
122
|
+
.badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
|
123
|
+
.badge.ok { color: var(--pass); background: var(--pass-bg); } .badge.fail { color: var(--fail); background: var(--fail-bg); }
|
|
124
|
+
.table { overflow-x: auto; }
|
|
125
|
+
table { width: 100%; border-collapse: collapse; }
|
|
126
|
+
th, td { text-align: left; vertical-align: top; padding: 8px; border-top: 1px solid var(--line); }
|
|
127
|
+
th { color: var(--muted); font-weight: 600; border-top: 0; }
|
|
128
|
+
td.n { white-space: nowrap; color: var(--muted); }
|
|
129
|
+
.err { color: var(--fail); margin-top: 4px; white-space: pre-wrap; } .heal { color: var(--warn); margin-top: 4px; }
|
|
130
|
+
img { max-width: 220px; border: 1px solid var(--line); border-radius: 6px; }
|
|
131
|
+
.images { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; } figure { margin: 0; } figcaption { color: var(--muted); font-size: 12px; }
|
|
132
|
+
code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
133
|
+
</style>
|
|
134
|
+
</head>
|
|
135
|
+
<body>
|
|
136
|
+
<main>
|
|
137
|
+
<header>
|
|
138
|
+
<h1>${escapeHtml(title)}: ${escapeHtml(r.name || r.goal || "")} <span class="badge ${r.ok ? "ok" : "fail"}">${status}</span></h1>
|
|
139
|
+
<div class="meta">${summary} · app ${escapeHtml(r.app || "")}${meta.environment ? ` · environment ${escapeHtml(meta.environment)}` : ""} · model ${escapeHtml(meta.model)} · ${escapeHtml(meta.when)}</div>
|
|
140
|
+
${r.error ? `<p class="err">${escapeHtml(r.error)}</p>` : ""}
|
|
141
|
+
${r.workflow ? `<p class="meta">Workflow: <code>${escapeHtml(r.workflow)}</code>${r.driver ? ` · driver ${escapeHtml(r.driver)}` : ""}</p>` : ""}
|
|
142
|
+
</header>
|
|
143
|
+
${rows ? `<section><h2>Steps</h2><div class="table"><table><thead><tr><th>#</th><th>Result</th><th>Op</th><th>Control</th><th>Time</th><th>Screen</th></tr></thead><tbody>${rows}</tbody></table></div></section>` : ""}
|
|
144
|
+
${flowsSection(r)}${heals}${skipped}${refused}
|
|
145
|
+
</main>
|
|
146
|
+
</body>
|
|
147
|
+
</html>
|
|
148
|
+
`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function writeDesktopReport(outDir: string, r: DesktopResult, meta: { model: string; when?: string; environment?: string; kind?: "Desktop" | "Mobile" }): string {
|
|
152
|
+
const file = path.join(outDir, "report.html")
|
|
153
|
+
writeFileSync(file, desktopReportHtml(r, { model: meta.model, when: meta.when || new Date().toISOString(), ...(meta.environment ? { environment: meta.environment } : {}), ...(meta.kind ? { kind: meta.kind } : {}) }))
|
|
154
|
+
return file
|
|
155
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import YAML from "yaml"
|
|
5
|
+
|
|
6
|
+
import { privateEnvNames } from "../childEnv.ts"
|
|
7
|
+
import type { AppConfig } from "../config.ts"
|
|
8
|
+
import type { LlmProfile } from "../llm.ts"
|
|
9
|
+
import { runDesktop, type DesktopEvent, type DesktopRequest, type DesktopResult } from "./bridge.ts"
|
|
10
|
+
import { writeDesktopReport } from "./report.ts"
|
|
11
|
+
import { ensureDesktop, ufoDir } from "./setup.ts"
|
|
12
|
+
|
|
13
|
+
/** Explore and replay as the CLI (`forgeqe desktop`) and ForgeQE's agent (desktop_* tools) both run them. */
|
|
14
|
+
|
|
15
|
+
const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
|
|
16
|
+
const VAR = /^([A-Za-z_][A-Za-z0-9_]{0,63})=(.*)$/s
|
|
17
|
+
const MAX_VALUE = 10_000
|
|
18
|
+
export const MAX_DATA_ROWS = 1000
|
|
19
|
+
|
|
20
|
+
/** `name=value` words as workflow variables. */
|
|
21
|
+
export function parseVariables(words: string[]): Record<string, string> {
|
|
22
|
+
const out: Record<string, string> = {}
|
|
23
|
+
for (const w of words) {
|
|
24
|
+
const m = VAR.exec(w)
|
|
25
|
+
if (!m) throw new Error(`"${w}" is not name=value; variables are letters, digits and _, e.g. customer=Contoso`)
|
|
26
|
+
out[m[1]!] = m[2]!
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Variables as a tool's caller gives them: names checked, values text, nothing else. */
|
|
32
|
+
export function checkVariables(raw: unknown): Record<string, string> {
|
|
33
|
+
if (raw === undefined || raw === null) return {}
|
|
34
|
+
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("variables must be an object of name to text")
|
|
35
|
+
const out: Record<string, string> = {}
|
|
36
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
37
|
+
if (!VAR_NAME.test(k)) throw new Error(`variable "${k}": names are letters, digits and _`)
|
|
38
|
+
if (typeof v !== "string" && typeof v !== "number") throw new Error(`variable ${k} must be text`)
|
|
39
|
+
const value = String(v)
|
|
40
|
+
if (value.length > MAX_VALUE) throw new Error(`variable ${k} is longer than ${MAX_VALUE} characters`)
|
|
41
|
+
out[k] = value
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A CSV file's rows as variables, one object per row, named by the header row. */
|
|
47
|
+
export function parseCsv(text: string): Record<string, string>[] {
|
|
48
|
+
const rows: string[][] = []
|
|
49
|
+
let row: string[] = []
|
|
50
|
+
let field = ""
|
|
51
|
+
let quoted = false
|
|
52
|
+
const src = text.replace(/^/, "")
|
|
53
|
+
for (let i = 0; i < src.length; i++) {
|
|
54
|
+
const c = src[i]!
|
|
55
|
+
if (quoted) {
|
|
56
|
+
if (c === '"' && src[i + 1] === '"') {
|
|
57
|
+
field += '"'
|
|
58
|
+
i++
|
|
59
|
+
} else if (c === '"') quoted = false
|
|
60
|
+
else field += c
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (c === '"' && field === "") quoted = true
|
|
64
|
+
else if (c === ",") {
|
|
65
|
+
row.push(field)
|
|
66
|
+
field = ""
|
|
67
|
+
} else if (c === "\n" || c === "\r") {
|
|
68
|
+
if (c === "\r" && src[i + 1] === "\n") i++
|
|
69
|
+
row.push(field)
|
|
70
|
+
field = ""
|
|
71
|
+
if (row.some((f) => f !== "")) rows.push(row)
|
|
72
|
+
row = []
|
|
73
|
+
} else field += c
|
|
74
|
+
}
|
|
75
|
+
if (quoted) throw new Error("the CSV file has an unclosed quote")
|
|
76
|
+
row.push(field)
|
|
77
|
+
if (row.some((f) => f !== "")) rows.push(row)
|
|
78
|
+
const [header, ...data] = rows
|
|
79
|
+
if (!header) throw new Error("the CSV file is empty")
|
|
80
|
+
const names = header.map((h) => h.trim())
|
|
81
|
+
for (const n of names) if (!VAR_NAME.test(n)) throw new Error(`CSV column "${n}": variable names are letters, digits and _`)
|
|
82
|
+
if (new Set(names).size !== names.length) throw new Error("the CSV header repeats a column")
|
|
83
|
+
if (data.length > MAX_DATA_ROWS) throw new Error(`the CSV file has ${data.length} rows; at most ${MAX_DATA_ROWS} run in one go`)
|
|
84
|
+
return data.map((r, i) => {
|
|
85
|
+
if (r.length !== names.length) throw new Error(`CSV row ${i + 2} has ${r.length} values; the header has ${names.length}`)
|
|
86
|
+
return Object.fromEntries(names.map((n, j) => [n, r[j]!]))
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** desktop.secrets that are ForgeQE's own (a model key, a tracker token): a workflow may never type those. */
|
|
91
|
+
export function privateDesktopSecrets(cfg: AppConfig): string[] {
|
|
92
|
+
const own = new Set(privateEnvNames(cfg))
|
|
93
|
+
return cfg.desktop.secrets.filter((n) => own.has(n))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Why a profile cannot drive explore (it sends screenshots), or null. */
|
|
97
|
+
export function visionProblem(name: string, p: LlmProfile): string | null {
|
|
98
|
+
const takes = typeof p.vision === "boolean" ? p.vision : p.provider !== "ollama"
|
|
99
|
+
return takes ? null : `llms.json profile "${name}" does not take images, and the desktop agent sends screenshots. Pick a vision model, or set "vision": true on the profile if it does take them.`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function hasModel(cfg: AppConfig): boolean {
|
|
103
|
+
return Boolean(cfg.llm.model) && cfg.llm.provider !== "none" && cfg.llm.provider !== "mock"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Throws when the config cannot run desktop work: ForgeQE's own keys in desktop.secrets, or no model when one is needed. */
|
|
107
|
+
export function checkDesktopConfig(cfg: AppConfig, needsModel: boolean): void {
|
|
108
|
+
const own = privateDesktopSecrets(cfg)
|
|
109
|
+
if (own.length) throw new Error(`desktop.secrets lists ${own.join(", ")}, which ForgeQE itself uses (a model key or a service token); a workflow may not type it.`)
|
|
110
|
+
if (needsModel && !hasModel(cfg)) throw new Error("No model is set up in llms.json. Run: forgeqe init, then forgeqe models (or replay with --no-heal).")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function checkApp(cfg: AppConfig, app: string): void {
|
|
114
|
+
if (!Object.prototype.hasOwnProperty.call(cfg.desktop.apps, app)) {
|
|
115
|
+
throw new Error(`App "${app}" is not in forgeqe.yaml desktop.apps (configured: ${Object.keys(cfg.desktop.apps).join(", ") || "none"}).`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stamp(): string {
|
|
120
|
+
return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function desktopOutDir(cfg: AppConfig, kind: "explore" | "replay" | "record", out?: string): string {
|
|
124
|
+
const dir = out ? path.resolve(out) : path.join(cfg.export.outDir, `desktop-${kind}-${stamp()}`)
|
|
125
|
+
mkdirSync(dir, { recursive: true })
|
|
126
|
+
return dir
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A workflow file a caller names, as its real path: a .yaml or .yml file inside one of `roots` (the working folder, the
|
|
131
|
+
* reports folder). A link that leads outside them is refused like a path that does.
|
|
132
|
+
*/
|
|
133
|
+
export function allowedWorkflow(file: string, roots: string[]): string {
|
|
134
|
+
if (!/\.ya?ml$/i.test(file)) throw new Error(`${file} is not a workflow (.yaml or .yml)`)
|
|
135
|
+
const resolved = path.resolve(roots[0] || process.cwd(), file)
|
|
136
|
+
if (!existsSync(resolved)) throw new Error(`No workflow at ${resolved}`)
|
|
137
|
+
const real = realpathSync(resolved)
|
|
138
|
+
for (const root of roots) {
|
|
139
|
+
if (!existsSync(root)) continue
|
|
140
|
+
const rel = path.relative(realpathSync(root), real)
|
|
141
|
+
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return real
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`${file} is outside the working folder and the reports folder; workflows are read from those only.`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Write accepted heals into the workflow, keeping its comments and layout. */
|
|
147
|
+
export function applyHeals(file: string, heals: NonNullable<DesktopResult["heals"]>): void {
|
|
148
|
+
const doc = YAML.parseDocument(readFileSync(file, "utf8"))
|
|
149
|
+
for (const h of heals) doc.setIn(["steps", h.index - 1, "target"], doc.createNode(h.new))
|
|
150
|
+
writeFileSync(file, doc.toString())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type DesktopRun = { result: DesktopResult; report: string; outDir: string }
|
|
154
|
+
|
|
155
|
+
function modelLabel(cfg: AppConfig): string {
|
|
156
|
+
return `${cfg.activeProfile} (${cfg.llm.provider}/${cfg.llm.deployment || cfg.llm.model})`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function finish(cfg: AppConfig, outDir: string, result: DesktopResult): Promise<DesktopRun> {
|
|
160
|
+
const report = writeDesktopReport(outDir, result, { model: modelLabel(cfg), ...(cfg.environments?.active ? { environment: cfg.environments.active } : {}) })
|
|
161
|
+
if (result.heals?.length) writeFileSync(path.join(outDir, "heals.json"), JSON.stringify(result.heals, null, 2))
|
|
162
|
+
return { result, report, outDir }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Replay one workflow; heals are returned and saved to heals.json, never written into the workflow here. */
|
|
166
|
+
export async function replayWorkflow(opts: {
|
|
167
|
+
cfg: AppConfig
|
|
168
|
+
workflow: string
|
|
169
|
+
variables?: Record<string, string>
|
|
170
|
+
heal: boolean
|
|
171
|
+
updateBaselines?: boolean
|
|
172
|
+
outDir: string
|
|
173
|
+
signal?: AbortSignal
|
|
174
|
+
onEvent?: (e: DesktopEvent) => void
|
|
175
|
+
log?: (text: string) => void
|
|
176
|
+
}): Promise<DesktopRun> {
|
|
177
|
+
const python = await ensureDesktop(opts.log || (() => {}))
|
|
178
|
+
const request: DesktopRequest = {
|
|
179
|
+
mode: "replay",
|
|
180
|
+
outDir: opts.outDir,
|
|
181
|
+
llm: opts.cfg.llm,
|
|
182
|
+
profileName: opts.cfg.activeProfile,
|
|
183
|
+
desktop: opts.cfg.desktop,
|
|
184
|
+
workflow: opts.workflow,
|
|
185
|
+
variables: opts.variables || {},
|
|
186
|
+
heal: opts.heal && hasModel(opts.cfg),
|
|
187
|
+
...(opts.updateBaselines ? { updateBaselines: true } : {}),
|
|
188
|
+
}
|
|
189
|
+
const result = await runDesktop({ python, request, signal: opts.signal, onEvent: opts.onEvent })
|
|
190
|
+
return finish(opts.cfg, opts.outDir, result)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Explore a goal with UFO² and save what it did, with checks of the outcome, as workflow.yaml. */
|
|
194
|
+
export async function exploreGoal(opts: {
|
|
195
|
+
cfg: AppConfig
|
|
196
|
+
goal: string
|
|
197
|
+
app: string
|
|
198
|
+
name?: string
|
|
199
|
+
outDir: string
|
|
200
|
+
signal?: AbortSignal
|
|
201
|
+
onEvent?: (e: DesktopEvent) => void
|
|
202
|
+
log?: (text: string) => void
|
|
203
|
+
}): Promise<DesktopRun> {
|
|
204
|
+
const python = await ensureDesktop(opts.log || (() => {}), { ufo: true })
|
|
205
|
+
const request: DesktopRequest = {
|
|
206
|
+
mode: "explore",
|
|
207
|
+
outDir: opts.outDir,
|
|
208
|
+
llm: opts.cfg.llm,
|
|
209
|
+
profileName: opts.cfg.activeProfile,
|
|
210
|
+
desktop: opts.cfg.desktop,
|
|
211
|
+
ufoDir: ufoDir(),
|
|
212
|
+
goal: opts.goal,
|
|
213
|
+
app: opts.app,
|
|
214
|
+
name: opts.name,
|
|
215
|
+
}
|
|
216
|
+
const result = await runDesktop({ python, request, signal: opts.signal, onEvent: opts.onEvent })
|
|
217
|
+
return finish(opts.cfg, opts.outDir, result)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Record by hand: start the app, and save what the tester does in it (until the hotkey, or the app closes) as workflow.yaml. */
|
|
221
|
+
export async function recordByHand(opts: {
|
|
222
|
+
cfg: AppConfig
|
|
223
|
+
app: string
|
|
224
|
+
name?: string
|
|
225
|
+
outDir: string
|
|
226
|
+
signal?: AbortSignal
|
|
227
|
+
onEvent?: (e: DesktopEvent) => void
|
|
228
|
+
log?: (text: string) => void
|
|
229
|
+
}): Promise<DesktopRun> {
|
|
230
|
+
const python = await ensureDesktop(opts.log || (() => {}))
|
|
231
|
+
const request: DesktopRequest = {
|
|
232
|
+
mode: "record",
|
|
233
|
+
outDir: opts.outDir,
|
|
234
|
+
llm: opts.cfg.llm,
|
|
235
|
+
profileName: opts.cfg.activeProfile,
|
|
236
|
+
desktop: opts.cfg.desktop,
|
|
237
|
+
app: opts.app,
|
|
238
|
+
...(opts.name ? { name: opts.name } : {}),
|
|
239
|
+
}
|
|
240
|
+
// A stop ends the recording; the engine keeps what was recorded, so it gets time to save it.
|
|
241
|
+
const result = await runDesktop({ python, request, signal: opts.signal, onEvent: opts.onEvent, graceMs: 60_000 })
|
|
242
|
+
return finish(opts.cfg, opts.outDir, result)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** What a finished recording or explore wrote, and what still has to be set before it replays. */
|
|
246
|
+
export function recordingNotes(cfg: AppConfig, result: DesktopResult): string[] {
|
|
247
|
+
const notes: string[] = []
|
|
248
|
+
const missing = (result.secrets || []).filter((n) => !cfg.desktop.secrets.includes(n))
|
|
249
|
+
if (missing.length) notes.push(`The workflow types ${missing.join(", ")} from password fields: set ${missing.length === 1 ? "it" : "them"} as environment variables and add ${missing.length === 1 ? "it" : "them"} to desktop.secrets (or the environment's secrets in env.yaml).`)
|
|
250
|
+
if (result.workflow && (result.checks ?? 0) === 0) notes.push(`No checks: replay proves only that the steps run. Record again with Ctrl+Shift+F11 over a control that shows the outcome, or add expect steps.`)
|
|
251
|
+
return notes
|
|
252
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { engineEnv, ensureUv, run, uvEnv } from "../engine/setup.ts"
|
|
6
|
+
import { forgeqeHome, type Log } from "../tools.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The desktop engine's own Python environment (~/.forgeqe/desktop/.venv), separate from the browser engine's: UFO pins
|
|
10
|
+
* its libraries (openai, pydantic) at versions the browser engine's library does not run with. Windows only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The UFO commit the desktop engine is tested with; also in py/forgeqe_desktop/ufo_runner.py (a test checks they match). */
|
|
14
|
+
export const UFO_COMMIT = "eae3c0a8d168c47cb10df0652e293e092eda1078"
|
|
15
|
+
export const UFO_REPO = "https://github.com/microsoft/UFO.git"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The UFO commit to set up: UFO_COMMIT, or FORGEQE_UFO_COMMIT when it is a full 40-character commit id, which is how
|
|
19
|
+
* the UFO update check (.github/workflows/forgeqe-desktop-ufo-update.yml) tries a newer UFO before it is pinned.
|
|
20
|
+
* Anything shorter or other than hex is refused, so the version set up is always exact.
|
|
21
|
+
*/
|
|
22
|
+
export function ufoCommit(env: NodeJS.ProcessEnv = process.env): string {
|
|
23
|
+
const raw = (env.FORGEQE_UFO_COMMIT || "").trim()
|
|
24
|
+
if (!raw) return UFO_COMMIT
|
|
25
|
+
if (!/^[0-9a-f]{40}$/.test(raw)) throw new Error("FORGEQE_UFO_COMMIT must be a full 40-character UFO commit id (lowercase hex).")
|
|
26
|
+
return raw
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** UFO's requirements pin pandas 1.4.3, which has Windows wheels up to Python 3.10 only. */
|
|
30
|
+
export const DESKTOP_PYTHON = "3.10"
|
|
31
|
+
|
|
32
|
+
/** Replay's libraries, at the versions UFO's requirements.txt pins, so one environment runs both. */
|
|
33
|
+
export const DESKTOP_PACKAGES = ["pywinauto==0.6.8", "PyYAML==6.0.1", "openai==1.66.2", "azure-identity==1.16.1", "Pillow==11.3.0", "comtypes==1.2.0"]
|
|
34
|
+
|
|
35
|
+
export const NOT_WINDOWS = "forgeqe desktop drives Windows apps, so explore, replay and setup run on Windows. validate and apps work anywhere."
|
|
36
|
+
|
|
37
|
+
export function desktopHome(): string {
|
|
38
|
+
return path.join(forgeqeHome(), "desktop")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function venvDir(): string {
|
|
42
|
+
return path.join(desktopHome(), ".venv")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function desktopPython(platform: string = process.platform): string {
|
|
46
|
+
return platform === "win32" ? path.join(venvDir(), "Scripts", "python.exe") : path.join(venvDir(), "bin", "python")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ufoDir(): string {
|
|
50
|
+
return path.join(desktopHome(), `ufo-${ufoCommit().slice(0, 12)}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function markerFile(): string {
|
|
54
|
+
return path.join(desktopHome(), "installed.json")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Marker = { packages?: string[]; ufo?: string; python?: string }
|
|
58
|
+
|
|
59
|
+
function marker(): Marker {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(readFileSync(markerFile(), "utf8")) as Marker
|
|
62
|
+
} catch {
|
|
63
|
+
return {}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type DesktopDoctor = { python: string; ready: boolean; ufo: boolean; error?: string }
|
|
68
|
+
|
|
69
|
+
/** Whether the environment imports what replay needs, and whether the pinned UFO checkout is in place. */
|
|
70
|
+
export function desktopDoctor(python: string = desktopPython()): DesktopDoctor {
|
|
71
|
+
if (!existsSync(python)) return { python, ready: false, ufo: false, error: "the desktop engine is not set up" }
|
|
72
|
+
const r = spawnSync(python, ["-c", "import pywinauto, yaml, openai, forgeqe_desktop.service"], { env: engineEnv(), encoding: "utf8" })
|
|
73
|
+
const ready = !r.error && r.status === 0
|
|
74
|
+
const m = marker()
|
|
75
|
+
const ufo = m.ufo === ufoCommit() && existsSync(path.join(ufoDir(), "ufo", "__init__.py"))
|
|
76
|
+
return { python, ready, ufo, ...(ready ? {} : { error: (r.stderr || r.error?.message || "").trim().split(/\r?\n/).slice(-1)[0] || "imports failed" }) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function gitHead(dir: string): string {
|
|
80
|
+
const r = spawnSync("git", ["-C", dir, "rev-parse", "HEAD"], { encoding: "utf8" })
|
|
81
|
+
return r.status === 0 ? r.stdout.trim() : ""
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Clone UFO and check out exactly ufoCommit(); the commit id is the content's hash, so a matching HEAD is the pinned code. */
|
|
85
|
+
async function ensureUfo(log: Log): Promise<string> {
|
|
86
|
+
const commit = ufoCommit()
|
|
87
|
+
const dir = ufoDir()
|
|
88
|
+
if (existsSync(dir) && gitHead(dir) === commit) return dir
|
|
89
|
+
const git = spawnSync("git", ["--version"], { encoding: "utf8" })
|
|
90
|
+
if (git.error || git.status !== 0) throw new Error("Setting up UFO needs git on PATH (https://git-scm.com/download/win).")
|
|
91
|
+
rmSync(dir, { recursive: true, force: true })
|
|
92
|
+
log(`Fetching UFO ${commit.slice(0, 12)}${commit === UFO_COMMIT ? "" : " (FORGEQE_UFO_COMMIT, not the pinned one)"} into ${dir}.`)
|
|
93
|
+
await run("git", ["clone", "--filter=blob:none", "--no-checkout", UFO_REPO, dir], log)
|
|
94
|
+
await run("git", ["-C", dir, "checkout", "--detach", commit], log)
|
|
95
|
+
const head = gitHead(dir)
|
|
96
|
+
if (head !== commit) {
|
|
97
|
+
rmSync(dir, { recursive: true, force: true })
|
|
98
|
+
throw new Error(`UFO checkout is at ${head || "nothing"}, not ${commit}; removed it.`)
|
|
99
|
+
}
|
|
100
|
+
return dir
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Set up the desktop engine: its environment with replay's libraries, and with `ufo` the pinned UFO checkout and its
|
|
105
|
+
* requirements (large: UFO's retrieval needs PyTorch). Returns the environment's Python.
|
|
106
|
+
*/
|
|
107
|
+
export async function ensureDesktop(log: Log, opts: { ufo?: boolean; force?: boolean; platform?: string } = {}): Promise<string> {
|
|
108
|
+
if ((opts.platform || process.platform) !== "win32") throw new Error(NOT_WINDOWS)
|
|
109
|
+
const python = desktopPython()
|
|
110
|
+
const m: Marker = opts.force ? {} : marker()
|
|
111
|
+
const packagesCurrent = JSON.stringify(m.packages || []) === JSON.stringify(DESKTOP_PACKAGES) && m.python === DESKTOP_PYTHON
|
|
112
|
+
if (!opts.force && packagesCurrent && existsSync(python) && (!opts.ufo || m.ufo === ufoCommit()) && desktopDoctor(python).ready) return python
|
|
113
|
+
|
|
114
|
+
// An environment on another Python (an earlier ForgeQE used 3.11) is rebuilt rather than mixed.
|
|
115
|
+
if (opts.force || (existsSync(python) && m.python !== DESKTOP_PYTHON)) rmSync(venvDir(), { recursive: true, force: true })
|
|
116
|
+
mkdirSync(desktopHome(), { recursive: true })
|
|
117
|
+
const uv = await ensureUv(log)
|
|
118
|
+
if (!uv) throw new Error("The desktop engine needs uv to set up its Python; uv could not be downloaded. Set HTTPS_PROXY if you are behind a proxy.")
|
|
119
|
+
log(`Setting up the desktop engine in ${venvDir()}.`)
|
|
120
|
+
if (!existsSync(python)) await run(uv, ["venv", "--python", DESKTOP_PYTHON, venvDir()], log, uvEnv(uv))
|
|
121
|
+
const extra: string[] = []
|
|
122
|
+
let ufo: string | undefined
|
|
123
|
+
if (opts.ufo) {
|
|
124
|
+
const dir = await ensureUfo(log)
|
|
125
|
+
extra.push("-r", path.join(dir, "requirements.txt"))
|
|
126
|
+
ufo = ufoCommit()
|
|
127
|
+
}
|
|
128
|
+
await run(uv, ["pip", "install", "--python", python, ...DESKTOP_PACKAGES, ...extra], log, uvEnv(uv), "uv pip install <desktop packages>")
|
|
129
|
+
const kept = m.python === DESKTOP_PYTHON ? m.ufo : undefined
|
|
130
|
+
writeFileSync(markerFile(), JSON.stringify({ packages: DESKTOP_PACKAGES, python: DESKTOP_PYTHON, ...(ufo || kept ? { ufo: ufo || kept } : {}) }, null, 2))
|
|
131
|
+
const doc = desktopDoctor(python)
|
|
132
|
+
if (!doc.ready) throw new Error(`The desktop engine was installed but does not start: ${doc.error}`)
|
|
133
|
+
return python
|
|
134
|
+
}
|
package/src/engine/bridge.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type { BrowserProvider, BrowserRemoteConfig, LoginConfig } from "../confi
|
|
|
11
11
|
import { debugLogging, logEvent } from "../sessionLog.ts"
|
|
12
12
|
import { engineEnv } from "./setup.ts"
|
|
13
13
|
import { AZURE_IDENTITY_ENV, entraPayload } from "../entra.ts"
|
|
14
|
+
import { ghAuthToken } from "../cases/github.ts"
|
|
15
|
+
import { engineRoute } from "../copilot.ts"
|
|
14
16
|
|
|
15
17
|
export type EngineRequest = {
|
|
16
18
|
goal: string
|
|
@@ -152,7 +154,7 @@ function toMemoryInfo(m: EngineMemory | undefined): MemoryInfo | undefined {
|
|
|
152
154
|
}
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
function llmPayload(p: LlmProfile): Record<string, unknown> {
|
|
157
|
+
export function llmPayload(p: LlmProfile): Record<string, unknown> {
|
|
156
158
|
const out: Record<string, unknown> = { provider: p.provider, model: p.model }
|
|
157
159
|
if (p.endpoint) out.endpoint = p.endpoint
|
|
158
160
|
if (p.deployment) out.deployment = p.deployment
|
|
@@ -161,6 +163,8 @@ function llmPayload(p: LlmProfile): Record<string, unknown> {
|
|
|
161
163
|
if (p.auth === "entra") Object.assign(out, entraPayload(p))
|
|
162
164
|
else if (p.apiKeyEnv) out.api_key_env = p.apiKeyEnv
|
|
163
165
|
if (typeof p.temperature === "number") out.temperature = p.temperature
|
|
166
|
+
if (typeof p.vision === "boolean") out.vision = p.vision
|
|
167
|
+
if (typeof p.reasoning === "boolean") out.reasoning = p.reasoning
|
|
164
168
|
return out
|
|
165
169
|
}
|
|
166
170
|
|
|
@@ -240,12 +244,15 @@ export async function runEngine(opts: {
|
|
|
240
244
|
// Each model call goes to the usage ledger under this run (its folder's name) and case; what the per-call events
|
|
241
245
|
// missed (an engine that sends only a total) is recorded as one unattributed call when the run ends.
|
|
242
246
|
const scope = { runId: path.basename(req.outDir), ...(req.caseId ? { caseId: req.caseId } : {}) }
|
|
243
|
-
|
|
247
|
+
// A github_copilot profile's engine runs on GitHub Models; its token goes only into the engine's environment.
|
|
248
|
+
const route = engineRoute(req.llm, ghAuthToken)
|
|
249
|
+
const llm = route.profile
|
|
250
|
+
const model = llm.deployment || llm.model
|
|
244
251
|
const callTotal: Usage = { input: 0, output: 0 }
|
|
245
252
|
const [bin, ...args] = opts.command || [opts.python, "-m", "forgeqe_engine", "run"]
|
|
246
253
|
const child = spawn(bin!, args, {
|
|
247
|
-
env: engineEnv(debugLogging() ? {
|
|
248
|
-
...(
|
|
254
|
+
env: engineEnv({ ...req.env, ...(debugLogging() ? { BROWSER_USE_LOGGING_LEVEL: "debug" } : {}), ...route.env }, [
|
|
255
|
+
...(llm.auth === "entra" ? AZURE_IDENTITY_ENV : llm.apiKeyEnv ? [llm.apiKeyEnv] : []),
|
|
249
256
|
...browserProviderEnvNames(req.browser || { provider: "local" }),
|
|
250
257
|
...loginEnvNames(req.logins || []),
|
|
251
258
|
]),
|
|
@@ -266,7 +273,7 @@ export async function runEngine(opts: {
|
|
|
266
273
|
const payload = {
|
|
267
274
|
goal: req.goal,
|
|
268
275
|
out_dir: req.outDir,
|
|
269
|
-
llm: llmPayload(
|
|
276
|
+
llm: llmPayload(llm),
|
|
270
277
|
start_url: req.startUrl || null,
|
|
271
278
|
headed: req.headed,
|
|
272
279
|
max_steps: req.maxSteps,
|
package/src/engine/setup.ts
CHANGED
|
@@ -68,6 +68,9 @@ export function engineEnv(extra?: Record<string, string>, allow: string[] = []):
|
|
|
68
68
|
const env = allowListEnv(process.env, allow)
|
|
69
69
|
env.PYTHONPATH = [ENGINE_SOURCE, env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
|
70
70
|
env.PYTHONUNBUFFERED = "1"
|
|
71
|
+
// Requests, prompts and logs are UTF-8; Windows would otherwise read pipes and files in its ANSI code page.
|
|
72
|
+
env.PYTHONUTF8 = "1"
|
|
73
|
+
env.PYTHONIOENCODING = "utf-8"
|
|
71
74
|
return { ...env, ...extra }
|
|
72
75
|
}
|
|
73
76
|
|
|
@@ -78,7 +81,7 @@ function managedUv(): string {
|
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/** A uv ForgeQE downloaded keeps its Pythons under ~/.forgeqe too, so deleting that folder removes everything. */
|
|
81
|
-
function uvEnv(uv: string): Record<string, string> {
|
|
84
|
+
export function uvEnv(uv: string): Record<string, string> {
|
|
82
85
|
return uv === managedUv() ? { UV_PYTHON_INSTALL_DIR: path.join(toolsDir(), "python") } : {}
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -103,7 +106,7 @@ export function uvAsset(platform: string = process.platform, arch: string = proc
|
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
/** System uv, else a uv ForgeQE downloaded earlier, else download it now. Null only when the download fails. */
|
|
106
|
-
async function ensureUv(log: Log): Promise<string | null> {
|
|
109
|
+
export async function ensureUv(log: Log): Promise<string | null> {
|
|
107
110
|
const found = findUv()
|
|
108
111
|
if (found) return found
|
|
109
112
|
try {
|
|
@@ -142,7 +145,7 @@ function findSystemPython(): string | null {
|
|
|
142
145
|
return null
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
function run(cmd: string, args: string[], log: Log, env: Record<string, string> = {}, shown?: string): Promise<void> {
|
|
148
|
+
export function run(cmd: string, args: string[], log: Log, env: Record<string, string> = {}, shown?: string): Promise<void> {
|
|
146
149
|
log(`$ ${shown || [path.basename(cmd), ...args].join(" ")}`)
|
|
147
150
|
return new Promise((resolve, reject) => {
|
|
148
151
|
const child = spawn(cmd, args, {
|