@capagents/forgeqe 0.0.0-stage → 1.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 +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process"
|
|
2
|
+
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
7
|
+
|
|
8
|
+
import { entraGateway } from "../entra.ts"
|
|
9
|
+
import { missingKey, type LlmProfile } from "../llm.ts"
|
|
10
|
+
import type { CodeEvent, Usage } from "../types.ts"
|
|
11
|
+
import { envRefs, withoutNames } from "../childEnv.ts"
|
|
12
|
+
import { addUsage, recordUsage, type Role } from "../usage.ts"
|
|
13
|
+
import { linkSkills, type Skill, type SkillAgent } from "../skills/registry.ts"
|
|
14
|
+
import { EXE, githubDigest, installTool, isMusl, toolPath, type Log } from "../tools.ts"
|
|
15
|
+
import { RESULT_SCHEMA, testAgentPrompt, type AgentReport } from "./prompt.ts"
|
|
16
|
+
import { protectPatterns } from "./worktree.ts"
|
|
17
|
+
|
|
18
|
+
export const AGENT_NAME = "forgeqe-test"
|
|
19
|
+
|
|
20
|
+
/** Matches the agent runtime SDK version in package.json. */
|
|
21
|
+
export const OPENCODE_VERSION = "1.18.33"
|
|
22
|
+
const OPENCODE_BIN = `opencode${EXE}`
|
|
23
|
+
|
|
24
|
+
function managedOpencode(): string {
|
|
25
|
+
return toolPath("opencode", OPENCODE_VERSION, OPENCODE_BIN)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The agent runtime binary: configured path or name, then PATH, then the installer's bin directory, then one ForgeQE downloaded. */
|
|
29
|
+
export function findOpencode(configured?: string): string | null {
|
|
30
|
+
if (configured) {
|
|
31
|
+
if (/[\\/]/.test(configured)) return existsSync(configured) ? configured : null
|
|
32
|
+
return Bun.which(configured)
|
|
33
|
+
}
|
|
34
|
+
const onPath = Bun.which("opencode")
|
|
35
|
+
if (onPath) return onPath
|
|
36
|
+
for (const c of [path.join(os.homedir(), ".opencode", "bin", OPENCODE_BIN), managedOpencode()]) if (existsSync(c)) return c
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Agent runtime release asset for this machine. x64 uses the baseline build so CPUs without AVX2 work too. */
|
|
41
|
+
export function opencodeAsset(platform: string = process.platform, arch: string = process.arch, musl = isMusl()): string | null {
|
|
42
|
+
const cpu = ({ x64: "x64-baseline", arm64: "arm64" } as Record<string, string>)[arch]
|
|
43
|
+
if (!cpu) return null
|
|
44
|
+
if (platform === "darwin") return `opencode-darwin-${cpu}.zip`
|
|
45
|
+
if (platform === "linux") return `opencode-linux-${cpu}${musl ? "-musl" : ""}.tar.gz`
|
|
46
|
+
if (platform === "win32") return `opencode-windows-${cpu}.zip`
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** findOpencode, else download the pinned agent runtime into ~/.forgeqe/tools. Null when code.runtime points elsewhere or the download fails. */
|
|
51
|
+
export async function ensureOpencode(configured: string | undefined, log: Log): Promise<string | null> {
|
|
52
|
+
const found = findOpencode(configured)
|
|
53
|
+
if (found || configured) return found
|
|
54
|
+
const asset = opencodeAsset()
|
|
55
|
+
if (!asset) return null
|
|
56
|
+
const repo = "anomalyco/opencode"
|
|
57
|
+
const tag = `v${OPENCODE_VERSION}`
|
|
58
|
+
try {
|
|
59
|
+
const sha256 = await githubDigest(repo, tag, asset)
|
|
60
|
+
const url = `https://github.com/${repo}/releases/download/${tag}/${asset}`
|
|
61
|
+
return await installTool({ name: "opencode", version: OPENCODE_VERSION, asset, url, binary: OPENCODE_BIN, sha256, log, label: `the agent runtime ${OPENCODE_VERSION}` })
|
|
62
|
+
} catch (err) {
|
|
63
|
+
log(`Could not download the agent runtime (${err instanceof Error ? err.message : String(err)}).`)
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const INSTALL_HINT = "The agent runtime could not be downloaded; run forgeqe setup once the network is reachable, or set code.runtime to its binary."
|
|
69
|
+
|
|
70
|
+
type ModelRef = { providerID: string; modelID: string }
|
|
71
|
+
|
|
72
|
+
const env = (name: string | undefined) => (name ? `{env:${name}}` : undefined)
|
|
73
|
+
|
|
74
|
+
function trimSlash(url: string): string {
|
|
75
|
+
return url.replace(/\/+$/, "")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Agent runtime provider config for a ForgeQE llms.json profile. Keys are referenced as {env:NAME}, which the agent runtime
|
|
80
|
+
* substitutes itself, so key values never leave the environment.
|
|
81
|
+
*/
|
|
82
|
+
export function providerFor(profile: LlmProfile): { provider: Record<string, unknown>; model: ModelRef } {
|
|
83
|
+
if (profile.auth === "entra") throw new Error("An auth: entra profile reaches the coding agent through entraGateway, which gives it a profile of its own.")
|
|
84
|
+
const key = profile.apiKeyEnv ? env(profile.apiKeyEnv) : profile.apiKey
|
|
85
|
+
const model = { tool_call: true, temperature: false, limit: { context: 200_000, output: 32_000 } }
|
|
86
|
+
switch (profile.provider) {
|
|
87
|
+
case "azure": {
|
|
88
|
+
if (!profile.endpoint) throw new Error("The azure profile needs an endpoint for the coding agent.")
|
|
89
|
+
const deployment = profile.deployment || profile.model
|
|
90
|
+
const origin = trimSlash(profile.endpoint).replace(/\/openai(\/v1)?$/, "")
|
|
91
|
+
return {
|
|
92
|
+
provider: {
|
|
93
|
+
azure: {
|
|
94
|
+
options: { baseURL: `${origin}/openai/v1`, ...(key ? { apiKey: key } : {}) },
|
|
95
|
+
models: { [deployment]: { id: deployment, name: profile.model, ...model } },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
model: { providerID: "azure", modelID: deployment },
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
case "openai":
|
|
102
|
+
return {
|
|
103
|
+
provider: {
|
|
104
|
+
openai: {
|
|
105
|
+
options: { ...(key ? { apiKey: key } : {}), ...(profile.endpoint ? { baseURL: trimSlash(profile.endpoint) } : {}) },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
model: { providerID: "openai", modelID: profile.model },
|
|
109
|
+
}
|
|
110
|
+
case "openai_compatible":
|
|
111
|
+
case "ollama":
|
|
112
|
+
if (!profile.endpoint) throw new Error(`The ${profile.provider} profile needs an endpoint for the coding agent.`)
|
|
113
|
+
return {
|
|
114
|
+
provider: {
|
|
115
|
+
forgeqe: {
|
|
116
|
+
npm: "@ai-sdk/openai-compatible",
|
|
117
|
+
name: "ForgeQE profile",
|
|
118
|
+
options: { baseURL: trimSlash(profile.endpoint), ...(key ? { apiKey: key } : {}) },
|
|
119
|
+
models: { [profile.model]: { name: profile.model, ...model } },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
model: { providerID: "forgeqe", modelID: profile.model },
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
throw new Error(`The coding agent needs a live LLM profile; "${profile.provider}" has no model. Pick one with --profile or set code.model.`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseModel(ref: string): ModelRef {
|
|
130
|
+
const i = ref.indexOf("/")
|
|
131
|
+
if (i <= 0) throw new Error(`code.model must be provider/model, got "${ref}".`)
|
|
132
|
+
return { providerID: ref.slice(0, i), modelID: ref.slice(i + 1) }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `permission` entries replace the defaults (edit, run anything but push, fetch) key by key. `skills` names whose
|
|
137
|
+
* registry skills the agent may load; without it every skill is denied.
|
|
138
|
+
*/
|
|
139
|
+
export type AgentSpec = {
|
|
140
|
+
name: string
|
|
141
|
+
description: string
|
|
142
|
+
prompt: string
|
|
143
|
+
permission?: Record<string, unknown>
|
|
144
|
+
skills?: SkillAgent
|
|
145
|
+
/** Most tool-calling steps in one turn; at the limit the agent must answer with what it has. */
|
|
146
|
+
steps?: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const TEST_AGENT: AgentSpec = {
|
|
150
|
+
name: AGENT_NAME,
|
|
151
|
+
description: "Turns a verified ForgeQE browser run into a passing test in this repository",
|
|
152
|
+
get prompt() {
|
|
153
|
+
return testAgentPrompt()
|
|
154
|
+
},
|
|
155
|
+
skills: "scribe",
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Scribe before the flow arrives: the same instructions, reading only (no file changes, no live browser). */
|
|
159
|
+
export const READ_AGENT: AgentSpec = {
|
|
160
|
+
name: "forgeqe-read",
|
|
161
|
+
description: "Learns the repository's test setup before ForgeQE hands over a verified flow",
|
|
162
|
+
get prompt() {
|
|
163
|
+
return testAgentPrompt()
|
|
164
|
+
},
|
|
165
|
+
permission: { edit: "deny", "live_*": "deny" },
|
|
166
|
+
skills: "scribe",
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The agent runtime's own skill discovery (~/.claude/skills, ~/.agents/skills, and the project's) stays off: skills
|
|
171
|
+
* come only from ForgeQE's registry, through `skills.paths`. The runtime accepts only 1/0 style values here.
|
|
172
|
+
*/
|
|
173
|
+
const SKILL_ENV: Record<string, string> = { OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1" }
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The agent runtime server's environment: this terminal's, without the secrets in `hide` that its config does not
|
|
177
|
+
* reference, plus `secrets` (values ForgeQE derived for {env:} references) and the runtime's own folder on PATH.
|
|
178
|
+
* Nothing here is written into process.env, so other children and later sessions never see it.
|
|
179
|
+
*/
|
|
180
|
+
export function serverEnv(opts: { bin: string; config: Record<string, unknown>; hide?: string[]; secrets?: Record<string, string> }): Record<string, string> {
|
|
181
|
+
const needed = new Set(envRefs(opts.config))
|
|
182
|
+
const env = withoutNames(process.env, (opts.hide || []).filter((n) => !needed.has(n)))
|
|
183
|
+
const binDir = path.dirname(opts.bin)
|
|
184
|
+
const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH"
|
|
185
|
+
const parts = (env[pathKey] || "").split(path.delimiter).filter(Boolean)
|
|
186
|
+
if (!parts.includes(binDir)) env[pathKey] = [binDir, ...parts].join(path.delimiter)
|
|
187
|
+
return { ...env, ...SKILL_ENV, ...opts.secrets, OPENCODE_CONFIG_CONTENT: JSON.stringify(opts.config) }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A runtime plugin that blanks `names` in the environment of every shell command the agents run, so a prompt injected
|
|
192
|
+
* by a page under test cannot read the model key or tracker tokens the runtime itself needs. Returns the plugin's URL.
|
|
193
|
+
*/
|
|
194
|
+
export function hideEnvPlugin(dir: string, names: string[]): string {
|
|
195
|
+
const file = path.join(dir, "forgeqe-hide-env.js")
|
|
196
|
+
writeFileSync(
|
|
197
|
+
file,
|
|
198
|
+
`const NAMES = ${JSON.stringify([...new Set(names)].sort())}\n` +
|
|
199
|
+
`export const ForgeQEHideEnv = async () => ({\n` +
|
|
200
|
+
` "shell.env": async (_input, output) => {\n` +
|
|
201
|
+
` for (const name of NAMES) output.env[name] = ""\n` +
|
|
202
|
+
` },\n` +
|
|
203
|
+
`})\n`,
|
|
204
|
+
)
|
|
205
|
+
return `file://${file.split(path.sep).join("/").replace(/^([A-Za-z]):/, "/$1:")}`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function stopProcess(proc: ChildProcess): void {
|
|
209
|
+
if (proc.exitCode !== null || proc.signalCode !== null) return
|
|
210
|
+
if (process.platform === "win32" && proc.pid) {
|
|
211
|
+
const out = spawnSync("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { windowsHide: true })
|
|
212
|
+
if (!out.error && out.status === 0) return
|
|
213
|
+
}
|
|
214
|
+
proc.kill()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** `opencode serve` on 127.0.0.1 with an explicit environment (the SDK's launcher copies process.env). */
|
|
218
|
+
export async function startServer(bin: string, env: Record<string, string>, timeoutMs: number): Promise<{ url: string; close(): void }> {
|
|
219
|
+
const proc = spawn(bin, ["serve", "--hostname=127.0.0.1", "--port=0"], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true })
|
|
220
|
+
const url = await new Promise<string>((resolve, reject) => {
|
|
221
|
+
let output = ""
|
|
222
|
+
const fail = (err: Error) => {
|
|
223
|
+
clearTimeout(timer)
|
|
224
|
+
stopProcess(proc)
|
|
225
|
+
reject(err)
|
|
226
|
+
}
|
|
227
|
+
const timer = setTimeout(() => fail(new Error(`Timeout waiting for the agent runtime to start after ${timeoutMs}ms`)), timeoutMs)
|
|
228
|
+
proc.stdout!.on("data", (chunk) => {
|
|
229
|
+
output += String(chunk)
|
|
230
|
+
const line = output.split("\n").find((l) => l.startsWith("opencode server listening"))
|
|
231
|
+
if (!line) return
|
|
232
|
+
const match = line.match(/on\s+(https?:\/\/\S+)/)
|
|
233
|
+
clearTimeout(timer)
|
|
234
|
+
if (match) resolve(match[1]!)
|
|
235
|
+
else fail(new Error(`Could not read the agent runtime's address from: ${line}`))
|
|
236
|
+
})
|
|
237
|
+
proc.stderr!.on("data", (chunk) => {
|
|
238
|
+
output += String(chunk)
|
|
239
|
+
})
|
|
240
|
+
proc.on("exit", (code) => fail(new Error(`The agent runtime exited with code ${code}${output.trim() ? `\n${output.trim()}` : ""}`)))
|
|
241
|
+
proc.on("error", fail)
|
|
242
|
+
})
|
|
243
|
+
return { url, close: () => stopProcess(proc) }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** `skill` permission for one agent: only the registry's skills for its audience; everything else, built-ins included, denied. */
|
|
247
|
+
export function skillPermission(skills: Skill[] | undefined, audience: SkillAgent | undefined): string | Record<string, string> {
|
|
248
|
+
const names = audience ? (skills || []).filter((s) => s.agents.includes(audience)).map((s) => s.name) : []
|
|
249
|
+
if (!names.length) return "deny"
|
|
250
|
+
return { "*": "deny", ...Object.fromEntries(names.map((n) => [n, "allow"])) }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Shell commands the agents may not run; pushing goes through ForgeQE's guarded push_branch tool. */
|
|
254
|
+
/**
|
|
255
|
+
* The runtime matches each command of a pipeline or `&&` chain on its own, so `cd x && git push` is caught by the plain
|
|
256
|
+
* rules; the `*`-prefixed ones catch a push wrapped in another command (`env git push`, `sh -c 'git push'`). These are
|
|
257
|
+
* guard rails, not a sandbox: worktree.ts puts back what the agent may not change.
|
|
258
|
+
*/
|
|
259
|
+
export const BASH_PERMISSION = {
|
|
260
|
+
"*": "allow",
|
|
261
|
+
"git push*": "deny",
|
|
262
|
+
"git * push*": "deny",
|
|
263
|
+
"*git push*": "deny",
|
|
264
|
+
"*git -C * push*": "deny",
|
|
265
|
+
"*git -c * push*": "deny",
|
|
266
|
+
"gh repo delete*": "deny",
|
|
267
|
+
"*gh repo delete*": "deny",
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Commands that throw away the user's uncommitted work, also when wrapped in another command. */
|
|
271
|
+
const DISCARD_WORK = ["git clean*", "git reset --hard*", "git stash*", "git restore*", "git checkout -- *", "git checkout .*"].flatMap((c) => [c, `*${c}`])
|
|
272
|
+
|
|
273
|
+
/** Paths the agent may not change: `edit` relative to the git worktree root as the agent runtime matches them, `bash` as they appear in commands. */
|
|
274
|
+
export type Protect = { edit: string[]; bash: string[] }
|
|
275
|
+
|
|
276
|
+
export function protectedPermission(protect: Protect, discardWork = true): { edit: Record<string, string>; bash: Record<string, string> } {
|
|
277
|
+
const edit: Record<string, string> = { "*": "allow" }
|
|
278
|
+
const bash: Record<string, string> = { ...BASH_PERMISSION }
|
|
279
|
+
for (const p of protect.edit) {
|
|
280
|
+
edit[p] = "deny"
|
|
281
|
+
edit[`${p}/*`] = "deny"
|
|
282
|
+
}
|
|
283
|
+
for (const p of protect.bash) bash[`*${p}*`] = "deny"
|
|
284
|
+
if (discardWork) for (const c of DISCARD_WORK) bash[c] = "deny"
|
|
285
|
+
return { edit, bash }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Inline agent runtime config: the model, the agent, MCP servers, and permissions for an unattended run. */
|
|
289
|
+
export function opencodeConfig(opts: {
|
|
290
|
+
profile: LlmProfile
|
|
291
|
+
model?: string
|
|
292
|
+
readDirs: string[]
|
|
293
|
+
agent?: AgentSpec
|
|
294
|
+
/** More agents in the same config, e.g. READ_AGENT; a prompt picks one by name. */
|
|
295
|
+
agents?: AgentSpec[]
|
|
296
|
+
mcp?: Record<string, unknown>
|
|
297
|
+
/** Paths the agent may read but never change: ForgeQE's files and run folders. */
|
|
298
|
+
protect?: Protect
|
|
299
|
+
/** Without `protect`: paths the agent still may not change (the skill folders). */
|
|
300
|
+
readOnly?: Protect
|
|
301
|
+
/** Registry skills for this session and the folder of links the runtime reads them from. */
|
|
302
|
+
skills?: Skill[]
|
|
303
|
+
skillsDir?: string
|
|
304
|
+
}): {
|
|
305
|
+
config: Record<string, unknown>
|
|
306
|
+
model: ModelRef
|
|
307
|
+
} {
|
|
308
|
+
const fromProfile = opts.model ? null : providerFor(opts.profile)
|
|
309
|
+
const model = opts.model ? parseModel(opts.model) : fromProfile!.model
|
|
310
|
+
const ref = `${model.providerID}/${model.modelID}`
|
|
311
|
+
const agent = opts.agent || TEST_AGENT
|
|
312
|
+
const external: Record<string, string> = { "*": "ask" }
|
|
313
|
+
for (const dir of opts.readDirs) external[`${dir}/**`] = "allow"
|
|
314
|
+
const entry = (spec: AgentSpec) => ({
|
|
315
|
+
description: spec.description,
|
|
316
|
+
mode: "primary",
|
|
317
|
+
prompt: spec.prompt,
|
|
318
|
+
...(spec.steps ? { steps: spec.steps } : {}),
|
|
319
|
+
permission: {
|
|
320
|
+
...(opts.protect ? protectedPermission(opts.protect) : opts.readOnly ? protectedPermission(opts.readOnly, false) : { edit: "allow", bash: BASH_PERMISSION }),
|
|
321
|
+
webfetch: "allow",
|
|
322
|
+
question: "deny",
|
|
323
|
+
doom_loop: "ask",
|
|
324
|
+
external_directory: external,
|
|
325
|
+
skill: skillPermission(opts.skills, spec.skills),
|
|
326
|
+
...(opts.mcp ? Object.fromEntries(Object.keys(opts.mcp).map((k) => [`${k}_*`, "allow"])) : {}),
|
|
327
|
+
...spec.permission,
|
|
328
|
+
},
|
|
329
|
+
})
|
|
330
|
+
return {
|
|
331
|
+
model,
|
|
332
|
+
config: {
|
|
333
|
+
$schema: "https://opencode.ai/config.json",
|
|
334
|
+
model: ref,
|
|
335
|
+
small_model: ref,
|
|
336
|
+
share: "disabled",
|
|
337
|
+
autoupdate: false,
|
|
338
|
+
experimental: { openTelemetry: false },
|
|
339
|
+
...(fromProfile ? { provider: fromProfile.provider } : {}),
|
|
340
|
+
...(opts.mcp ? { mcp: opts.mcp } : {}),
|
|
341
|
+
...(opts.skillsDir ? { skills: { paths: [opts.skillsDir] } } : {}),
|
|
342
|
+
agent: Object.fromEntries([agent, ...(opts.agents || [])].map((spec) => [spec.name, entry(spec)])),
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function realpathOr(dir: string): string {
|
|
348
|
+
try {
|
|
349
|
+
return realpathSync(dir)
|
|
350
|
+
} catch {
|
|
351
|
+
return path.resolve(dir)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function rel(repo: string, p: unknown): string {
|
|
356
|
+
const s = String(p || "")
|
|
357
|
+
if (!s) return ""
|
|
358
|
+
const r = path.relative(repo, s)
|
|
359
|
+
return r && !r.startsWith("..") ? r : s
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const clip = (s: string, n: number) => {
|
|
363
|
+
const one = s.replace(/\s+/g, " ").trim()
|
|
364
|
+
return one.length <= n ? one : `${one.slice(0, n - 1)}…`
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** A tool error without the `<<nonce>> [UNTRUSTED … CONTENT …] <<nonce>> … <</nonce>>` wrapper some MCP servers put around results. */
|
|
368
|
+
export function toolError(s: string): string {
|
|
369
|
+
return s
|
|
370
|
+
.replace(/<<[0-9a-f]{16,}>>\s*\[UNTRUSTED[^\]]*\]\s*<<[0-9a-f]{16,}>>/gi, "")
|
|
371
|
+
.replace(/<<\/?[0-9a-f]{16,}>>/gi, "")
|
|
372
|
+
.trim()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const QUIET_TOOLS = new Set(["StructuredOutput", "todowrite", "todoread"])
|
|
376
|
+
|
|
377
|
+
const FILE_VERBS: Record<string, string> = { read: "Reading", write: "Writing", edit: "Editing", patch: "Editing", multiedit: "Editing" }
|
|
378
|
+
|
|
379
|
+
/** One line for a finished tool call, e.g. `Editing tests/search.spec.ts` or `Running npx playwright test …`. */
|
|
380
|
+
export function toolLine(repo: string, tool: string, input: Record<string, unknown>, title?: string): string {
|
|
381
|
+
switch (tool) {
|
|
382
|
+
case "read":
|
|
383
|
+
case "edit":
|
|
384
|
+
case "write":
|
|
385
|
+
case "patch":
|
|
386
|
+
case "multiedit":
|
|
387
|
+
return `${FILE_VERBS[tool]} ${rel(repo, input.filePath || input.path) || title || ""}`.trim()
|
|
388
|
+
case "bash":
|
|
389
|
+
return `Running ${clip(String(input.command || title || ""), 100)}`
|
|
390
|
+
case "glob":
|
|
391
|
+
return `Finding files ${clip(String(input.pattern || ""), 60)}${input.path ? ` in ${rel(repo, input.path)}` : ""}`
|
|
392
|
+
case "grep":
|
|
393
|
+
return `Searching for ${clip(String(input.pattern || ""), 60)}${input.path ? ` in ${rel(repo, input.path)}` : ""}`
|
|
394
|
+
case "list":
|
|
395
|
+
return `Listing ${rel(repo, input.path) || "."}`
|
|
396
|
+
case "webfetch":
|
|
397
|
+
return `Fetching ${input.url || ""}`
|
|
398
|
+
case "task":
|
|
399
|
+
return `Delegating: ${clip(String(input.description || title || ""), 80)}`
|
|
400
|
+
case "skill":
|
|
401
|
+
return `Using skill ${input.name || title || ""}`.trim()
|
|
402
|
+
case "live_browser_navigate":
|
|
403
|
+
return `Live browser: open ${input.url || ""}`
|
|
404
|
+
default: {
|
|
405
|
+
if (tool.startsWith("live_browser_")) return `Live browser: ${tool.slice(13).replace(/_/g, " ")}${title ? ` ${clip(title, 80)}` : ""}`
|
|
406
|
+
const server = MCP_SERVERS.find(([prefix]) => tool.startsWith(prefix))
|
|
407
|
+
if (server) return `${server[1]} · ${tool.slice(server[0].length).replace(/_/g, " ")}${mcpArgs(input)}`
|
|
408
|
+
return `${tool}${title ? ` ${clip(title, 80)}` : ""}`
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** MCP servers ForgeQE's agent gets (external.ts), by tool-name prefix, as the transcript names them. */
|
|
414
|
+
const MCP_SERVERS: [string, string][] = [
|
|
415
|
+
["azure_devops_", "Azure DevOps"],
|
|
416
|
+
["atlassian_", "Atlassian"],
|
|
417
|
+
["confluence_", "Confluence"],
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
/** The short values an MCP call was made with (ids, keys, project), so the line says what was fetched. */
|
|
421
|
+
export function mcpArgs(input: Record<string, unknown>): string {
|
|
422
|
+
const parts = Object.entries(input || {})
|
|
423
|
+
.filter(([k, v]) => !/token|secret|password|^auth|api_?key/i.test(k) && (["string", "number", "boolean"].includes(typeof v) || (Array.isArray(v) && v.length > 0 && v.length <= 10 && v.every((x) => typeof x === "number" || typeof x === "string"))))
|
|
424
|
+
.map(([k, v]) => `${k.replace(/_/g, " ")} ${clip(Array.isArray(v) ? v.join(", ") : String(v).replace(/\s+/g, " "), 40)}`)
|
|
425
|
+
.slice(0, 4)
|
|
426
|
+
return parts.length ? ` (${parts.join(", ")})` : ""
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
type AnyEvent = { type: string; properties: Record<string, any> }
|
|
430
|
+
|
|
431
|
+
/** Why a permission request outside the config was declined, in words the agent can act on. */
|
|
432
|
+
export function declineMessage(permission: string): string {
|
|
433
|
+
if (permission === "doom_loop") return "You are repeating the same call. Try a different approach."
|
|
434
|
+
if (permission === "external_directory") return "Stay inside the repository."
|
|
435
|
+
return `ForgeQE runs unattended and does not grant ${permission || "this"} permission. Do the task without it.`
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** A shell command that kept failing: the turn was stopped after its `count`th failure. */
|
|
439
|
+
export type Stall = { command: string; count: number; output: string }
|
|
440
|
+
|
|
441
|
+
export type PromptOutcome<R = AgentReport> = { report: R | null; text: string; error?: string; stalled?: Stall }
|
|
442
|
+
|
|
443
|
+
/** Failures of each exact shell command since the last reset; `add` returns the stall once a command reaches `limit`. */
|
|
444
|
+
export class FailedCommands {
|
|
445
|
+
private counts = new Map<string, number>()
|
|
446
|
+
constructor(private limit: number) {}
|
|
447
|
+
add(command: string, exit: unknown, output: string): Stall | null {
|
|
448
|
+
if (typeof exit !== "number" || exit === 0 || !command.trim()) return null
|
|
449
|
+
const count = (this.counts.get(command) || 0) + 1
|
|
450
|
+
this.counts.set(command, count)
|
|
451
|
+
return count >= this.limit ? { command, count, output: output.split("\n").slice(-40).join("\n").trim() } : null
|
|
452
|
+
}
|
|
453
|
+
reset(): void {
|
|
454
|
+
this.counts.clear()
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Per prompt: tools switched on or off by name (`*` patterns allowed; the setting stays for later prompts, and `true`
|
|
460
|
+
* overrides the agent's path rules for that tool, so never pass it for file tools), whether the turn must end with the
|
|
461
|
+
* structured answer, and which of the session's agents answers.
|
|
462
|
+
*/
|
|
463
|
+
export type PromptOptions = { tools?: Record<string, boolean>; structured?: boolean; agent?: string }
|
|
464
|
+
|
|
465
|
+
export type OpencodeSession<R = AgentReport> = {
|
|
466
|
+
sessionId: string
|
|
467
|
+
/** The session asked for with `resume` was reopened, with its earlier messages. */
|
|
468
|
+
resumed?: boolean
|
|
469
|
+
model: string
|
|
470
|
+
prompt(text: string, options?: PromptOptions): Promise<PromptOutcome<R>>
|
|
471
|
+
abort(): Promise<void>
|
|
472
|
+
close(): Promise<void>
|
|
473
|
+
usage: Usage
|
|
474
|
+
/** Forget the failed commands counted so far (ForgeQE ran the test, so the agent starts a fresh round). */
|
|
475
|
+
resetStall?(): void
|
|
476
|
+
/** Each MCP server's state: connected, failed (with error), needs_auth, … */
|
|
477
|
+
mcpStatus?(): Promise<Record<string, { status: string; error?: string }>>
|
|
478
|
+
/** Skills the agent loaded so far, in order. */
|
|
479
|
+
skillsUsed?: string[]
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Start the agent runtime server with ForgeQE's inline config, open a session in the repo, and relay its events.
|
|
484
|
+
* Every permission request is outside the config (paths outside the repo, repeated identical tool calls, the runtime's
|
|
485
|
+
* own "ask" defaults such as reading .env files), so each is declined and an unattended run never waits for a human.
|
|
486
|
+
*/
|
|
487
|
+
/** The agent with the product's domain added to its instructions (once, when its prompt does not carry it already). */
|
|
488
|
+
export function withDomain(agent: AgentSpec, domain: string | null): AgentSpec {
|
|
489
|
+
const prompt = agent.prompt
|
|
490
|
+
return domain && !prompt.includes(domain) ? { ...agent, prompt: `${prompt}\n\n${domain}` } : agent
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export async function openSession<R = AgentReport>(opts: {
|
|
494
|
+
repo: string
|
|
495
|
+
profile: LlmProfile
|
|
496
|
+
model?: string
|
|
497
|
+
bin?: string
|
|
498
|
+
readDirs: string[]
|
|
499
|
+
signal?: AbortSignal
|
|
500
|
+
onEvent?: (e: CodeEvent) => void
|
|
501
|
+
/** Default: the test-writing agent. */
|
|
502
|
+
agent?: AgentSpec
|
|
503
|
+
agents?: AgentSpec[]
|
|
504
|
+
/** JSON schema of the answer each prompt must end with. Default: the test result. */
|
|
505
|
+
schema?: Record<string, unknown>
|
|
506
|
+
mcp?: Record<string, unknown>
|
|
507
|
+
/** Tool calls not to show as ⚙ lines (the caller shows them itself). */
|
|
508
|
+
hideTool?: (tool: string) => boolean
|
|
509
|
+
title?: string
|
|
510
|
+
protect?: Protect
|
|
511
|
+
/** Stop the turn when one shell command has failed this many times since the last resetStall. */
|
|
512
|
+
stallAfter?: number
|
|
513
|
+
/** Reopen this earlier session (the agent runtime keeps sessions on disk); a new one is created when it is gone. */
|
|
514
|
+
resume?: string
|
|
515
|
+
/** Registry skills; each agent may load the ones for its audience (AgentSpec.skills). */
|
|
516
|
+
skills?: Skill[]
|
|
517
|
+
/**
|
|
518
|
+
* ForgeQE's own secrets (privateEnvNames): left out of the runtime unless its config references them, and blanked
|
|
519
|
+
* in every shell command the agents run either way.
|
|
520
|
+
*/
|
|
521
|
+
hide?: string[]
|
|
522
|
+
/**
|
|
523
|
+
* The product's domain (domainNotes: its industry and regulations, or how to work them out), added to every agent's
|
|
524
|
+
* instructions so each one applies it. Required, so no new kind of session can leave it out; null only where it must
|
|
525
|
+
* not apply (a judge that is given the domain as data).
|
|
526
|
+
*/
|
|
527
|
+
domain: string | null
|
|
528
|
+
/** Values for {env:} references in `mcp` that are not in this terminal (e.g. base64 of email:token). */
|
|
529
|
+
secrets?: Record<string, string>
|
|
530
|
+
/** Who this session is in the usage meter. Default Scribe. */
|
|
531
|
+
role?: Role
|
|
532
|
+
}): Promise<OpencodeSession<R>> {
|
|
533
|
+
const missing = opts.model ? null : missingKey(opts.profile)
|
|
534
|
+
if (missing) throw new Error(missing)
|
|
535
|
+
const bin = findOpencode(opts.bin)
|
|
536
|
+
if (!bin) throw new Error(`The agent runtime was not found. ${INSTALL_HINT}`)
|
|
537
|
+
|
|
538
|
+
const agent = opts.agent || TEST_AGENT
|
|
539
|
+
const role: Role = opts.role || "Scribe"
|
|
540
|
+
// With code.model the runtime picks the model and its price is unknown; otherwise the profile's price applies.
|
|
541
|
+
const price = opts.model ? undefined : opts.profile.price
|
|
542
|
+
const audiences = new Set([agent, ...(opts.agents || [])].map((a) => a.skills).filter(Boolean))
|
|
543
|
+
const skills = (opts.skills || []).filter((s) => s.agents.some((a) => audiences.has(a)))
|
|
544
|
+
const links = linkSkills(skills)
|
|
545
|
+
const skillDirs = links ? [links.dir, ...skills.map((s) => s.dir)] : []
|
|
546
|
+
const readDirs = [...new Set([...opts.readDirs, ...skillDirs.flatMap((d) => [d, realpathOr(d)])])]
|
|
547
|
+
let protect = opts.protect
|
|
548
|
+
// ForgeQE's agent may change the user's and the project's skills when asked; built-in and global ones stay read-only.
|
|
549
|
+
const lockedDirs = audiences.has("forgeqe") ? [...(links ? [links.dir] : []), ...skills.filter((s) => s.source === "prebuilt" || s.source === "external").map((s) => s.dir)] : skillDirs
|
|
550
|
+
const skillPaths = lockedDirs.length ? protectPatterns(opts.repo, [], lockedDirs) : undefined
|
|
551
|
+
if (protect && skillPaths) protect = { edit: [...protect.edit, ...skillPaths.edit], bash: [...protect.bash, ...skillPaths.bash] }
|
|
552
|
+
// With auth: entra the runtime gets a local gateway that adds an Entra ID token to each request; it holds no key.
|
|
553
|
+
const gateway = !opts.model && opts.profile.auth === "entra" ? entraGateway(opts.profile) : null
|
|
554
|
+
const secrets = { ...opts.secrets, ...gateway?.secrets }
|
|
555
|
+
let built: ReturnType<typeof opencodeConfig>
|
|
556
|
+
try {
|
|
557
|
+
built = opencodeConfig({
|
|
558
|
+
profile: gateway?.profile || opts.profile,
|
|
559
|
+
model: opts.model,
|
|
560
|
+
readDirs,
|
|
561
|
+
agent: withDomain(agent, opts.domain),
|
|
562
|
+
agents: opts.agents?.map((a) => withDomain(a, opts.domain)),
|
|
563
|
+
mcp: opts.mcp,
|
|
564
|
+
protect,
|
|
565
|
+
readOnly: skillPaths,
|
|
566
|
+
skills,
|
|
567
|
+
skillsDir: links?.dir,
|
|
568
|
+
})
|
|
569
|
+
} catch (err) {
|
|
570
|
+
gateway?.close()
|
|
571
|
+
throw err
|
|
572
|
+
}
|
|
573
|
+
const { config, model } = built
|
|
574
|
+
const pluginDir = mkdtempSync(path.join(os.tmpdir(), "forgeqe-runtime-"))
|
|
575
|
+
const shellHidden = [...(opts.hide || []), ...Object.keys(secrets), ...envRefs(config), "OPENCODE_CONFIG_CONTENT"]
|
|
576
|
+
config.plugin = [...((config.plugin as string[] | undefined) || []), hideEnvPlugin(pluginDir, shellHidden)]
|
|
577
|
+
const cleanup = () => {
|
|
578
|
+
links?.remove()
|
|
579
|
+
gateway?.close()
|
|
580
|
+
rmSync(pluginDir, { recursive: true, force: true })
|
|
581
|
+
}
|
|
582
|
+
let server: { url: string; close(): void }
|
|
583
|
+
try {
|
|
584
|
+
server = await startServer(bin, serverEnv({ bin, config, hide: opts.hide, secrets }), 60_000)
|
|
585
|
+
} catch (err) {
|
|
586
|
+
cleanup()
|
|
587
|
+
throw err
|
|
588
|
+
}
|
|
589
|
+
const client: OpencodeClient = createOpencodeClient({ baseUrl: server.url, directory: opts.repo })
|
|
590
|
+
const emit = opts.onEvent || (() => {})
|
|
591
|
+
const usage: Usage = { input: 0, output: 0 }
|
|
592
|
+
|
|
593
|
+
let session: { id: string }
|
|
594
|
+
let resumed = false
|
|
595
|
+
try {
|
|
596
|
+
const earlier = opts.resume ? await client.session.get({ sessionID: opts.resume }).catch(() => null) : null
|
|
597
|
+
if (earlier?.data?.id) {
|
|
598
|
+
session = earlier.data
|
|
599
|
+
resumed = true
|
|
600
|
+
} else {
|
|
601
|
+
const created = await client.session.create({ title: opts.title || "ForgeQE test" })
|
|
602
|
+
if (!created.data) throw new Error(`The agent runtime could not create a session: ${JSON.stringify(created.error)}`)
|
|
603
|
+
session = created.data
|
|
604
|
+
}
|
|
605
|
+
} catch (err) {
|
|
606
|
+
server.close()
|
|
607
|
+
cleanup()
|
|
608
|
+
throw err
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const skillsUsed: string[] = []
|
|
612
|
+
const assistant = new Set<string>()
|
|
613
|
+
const said = new Set<string>()
|
|
614
|
+
let idle: (() => void) | null = null
|
|
615
|
+
let sessionError = ""
|
|
616
|
+
// The turn's result comes from events: in agent runtime 1.18 listing a session's messages fails once a prompt used `format`.
|
|
617
|
+
let turn: { structured: R | null; error: string; text: string[]; stalled?: Stall } = { structured: null, error: "", text: [] }
|
|
618
|
+
const failed = opts.stallAfter ? new FailedCommands(opts.stallAfter) : null
|
|
619
|
+
|
|
620
|
+
const handle = async (e: AnyEvent) => {
|
|
621
|
+
const p = e.properties || {}
|
|
622
|
+
const sid = p.sessionID ?? p.part?.sessionID ?? p.info?.sessionID
|
|
623
|
+
if (sid && sid !== session.id) return
|
|
624
|
+
switch (e.type) {
|
|
625
|
+
case "message.updated":
|
|
626
|
+
if (p.info?.role === "assistant") {
|
|
627
|
+
assistant.add(p.info.id)
|
|
628
|
+
if (p.info.structured && typeof p.info.structured === "object") turn.structured = p.info.structured
|
|
629
|
+
if (p.info.error) turn.error = `${p.info.error.name}: ${p.info.error.data?.message || ""}`.trim()
|
|
630
|
+
}
|
|
631
|
+
break
|
|
632
|
+
case "message.part.updated": {
|
|
633
|
+
const part = p.part
|
|
634
|
+
if (part?.type === "tool" && (part.state?.status === "completed" || part.state?.status === "error")) {
|
|
635
|
+
if (part.tool === "StructuredOutput" && part.state.status === "completed" && !turn.structured) {
|
|
636
|
+
turn.structured = part.state.input || null
|
|
637
|
+
}
|
|
638
|
+
if (part.tool === "bash" && failed && !turn.stalled) {
|
|
639
|
+
const stall = failed.add(String(part.state.input?.command || ""), part.state.metadata?.exit, String(part.state.metadata?.output || part.state.output || ""))
|
|
640
|
+
if (stall) {
|
|
641
|
+
turn.stalled = stall
|
|
642
|
+
emit({ type: "code", kind: "info", text: `Stopped the turn: ${clip(stall.command, 80)} failed ${stall.count} times` })
|
|
643
|
+
void client.session.abort({ sessionID: session.id }).catch(() => {})
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (part.tool === "skill" && part.state.status === "completed") {
|
|
647
|
+
const name = String(part.state.input?.name || "")
|
|
648
|
+
if (name && !skillsUsed.includes(name)) skillsUsed.push(name)
|
|
649
|
+
}
|
|
650
|
+
if (QUIET_TOOLS.has(part.tool) || opts.hideTool?.(part.tool)) break
|
|
651
|
+
const line = toolLine(opts.repo, part.tool, part.state.input || {}, part.state.title)
|
|
652
|
+
const ok = part.state.status === "completed"
|
|
653
|
+
emit({ type: "code", kind: "tool", ok, text: ok ? line : `${line}: ${clip(toolError(String(part.state.error || "")) || "failed", 120)}` })
|
|
654
|
+
} else if (part?.type === "text" && part.time?.end && assistant.has(part.messageID) && !said.has(part.id)) {
|
|
655
|
+
said.add(part.id)
|
|
656
|
+
const text = String(part.text || "").trim()
|
|
657
|
+
if (text) {
|
|
658
|
+
turn.text.push(text)
|
|
659
|
+
emit({ type: "code", kind: "say", text: clip(text, 400) })
|
|
660
|
+
}
|
|
661
|
+
} else if (part?.type === "step-finish" && part.tokens) {
|
|
662
|
+
const t = part.tokens
|
|
663
|
+
const step: Usage = {
|
|
664
|
+
input: (t.input || 0) + (t.cache?.read || 0) + (t.cache?.write || 0),
|
|
665
|
+
output: (t.output || 0) + (t.reasoning || 0),
|
|
666
|
+
...(t.cache?.read ? { cached: t.cache.read } : {}),
|
|
667
|
+
...(t.cache?.write ? { cacheWrite: t.cache.write } : {}),
|
|
668
|
+
}
|
|
669
|
+
Object.assign(usage, addUsage(usage, step))
|
|
670
|
+
recordUsage(role, step, price)
|
|
671
|
+
emit({ type: "code", kind: "usage", usage: { ...usage } })
|
|
672
|
+
}
|
|
673
|
+
break
|
|
674
|
+
}
|
|
675
|
+
case "session.status":
|
|
676
|
+
if (p.status?.type === "retry") emit({ type: "code", kind: "info", text: `Agent runtime retrying: ${clip(String(p.status.message || ""), 160)}` })
|
|
677
|
+
if (p.status?.type === "idle") idle?.()
|
|
678
|
+
break
|
|
679
|
+
case "session.idle":
|
|
680
|
+
idle?.()
|
|
681
|
+
break
|
|
682
|
+
case "session.error": {
|
|
683
|
+
const err = p.error
|
|
684
|
+
sessionError = String(err?.data?.message || err?.message || err?.name || "agent runtime error")
|
|
685
|
+
break
|
|
686
|
+
}
|
|
687
|
+
case "permission.asked": {
|
|
688
|
+
// The config allows or denies everything an agent needs, so a request that still asks is outside it: decline it.
|
|
689
|
+
await client.permission.reply({ requestID: p.id, reply: "reject", message: declineMessage(String(p.permission || "")) })
|
|
690
|
+
emit({ type: "code", kind: "info", text: `declined ${p.permission} ${(p.patterns || []).join(", ")}` })
|
|
691
|
+
break
|
|
692
|
+
}
|
|
693
|
+
case "question.asked":
|
|
694
|
+
await client.question.reject({ requestID: p.id })
|
|
695
|
+
break
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const sub = await client.event.subscribe({}, { sseMaxRetryAttempts: 1, onSseError: () => {} })
|
|
700
|
+
;(async () => {
|
|
701
|
+
try {
|
|
702
|
+
for await (const e of sub.stream as AsyncIterable<AnyEvent>) await handle(e).catch(() => {})
|
|
703
|
+
} catch {
|
|
704
|
+
// stream closed
|
|
705
|
+
}
|
|
706
|
+
})()
|
|
707
|
+
|
|
708
|
+
const abort = async () => {
|
|
709
|
+
await client.session.abort({ sessionID: session.id }).catch(() => {})
|
|
710
|
+
}
|
|
711
|
+
opts.signal?.addEventListener("abort", () => void abort(), { once: true })
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
sessionId: session.id,
|
|
715
|
+
resumed,
|
|
716
|
+
model: `${model.providerID}/${model.modelID}`,
|
|
717
|
+
usage,
|
|
718
|
+
abort,
|
|
719
|
+
async prompt(text: string, options: PromptOptions = {}): Promise<PromptOutcome<R>> {
|
|
720
|
+
sessionError = ""
|
|
721
|
+
turn = { structured: null, error: "", text: [] }
|
|
722
|
+
const done = new Promise<void>((resolve) => {
|
|
723
|
+
idle = resolve
|
|
724
|
+
})
|
|
725
|
+
const sent = await client.session.promptAsync({
|
|
726
|
+
sessionID: session.id,
|
|
727
|
+
agent: options.agent || agent.name,
|
|
728
|
+
model,
|
|
729
|
+
...(options.structured === false ? {} : { format: { type: "json_schema" as const, schema: (opts.schema || RESULT_SCHEMA) as never } }),
|
|
730
|
+
...(options.tools ? { tools: options.tools } : {}),
|
|
731
|
+
parts: [{ type: "text", text }],
|
|
732
|
+
})
|
|
733
|
+
if (sent.error) return { report: null, text: "", error: `The agent runtime rejected the prompt: ${JSON.stringify(sent.error)}` }
|
|
734
|
+
const onAbort = () => idle?.()
|
|
735
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true })
|
|
736
|
+
await done
|
|
737
|
+
opts.signal?.removeEventListener("abort", onAbort)
|
|
738
|
+
idle = null
|
|
739
|
+
if (opts.signal?.aborted) return { report: null, text: "", error: "stopped" }
|
|
740
|
+
await Bun.sleep(100)
|
|
741
|
+
if (turn.stalled) return { report: null, text: turn.text.join("\n"), stalled: turn.stalled }
|
|
742
|
+
return { report: turn.structured, text: turn.text.join("\n"), error: turn.error || sessionError || undefined }
|
|
743
|
+
},
|
|
744
|
+
resetStall() {
|
|
745
|
+
failed?.reset()
|
|
746
|
+
},
|
|
747
|
+
skillsUsed,
|
|
748
|
+
async close() {
|
|
749
|
+
server.close()
|
|
750
|
+
cleanup()
|
|
751
|
+
},
|
|
752
|
+
async mcpStatus() {
|
|
753
|
+
const res = await client.mcp.status()
|
|
754
|
+
return (res.data || {}) as Record<string, { status: string; error?: string }>
|
|
755
|
+
},
|
|
756
|
+
}
|
|
757
|
+
}
|