From d8649ac6f4d4160c095c0a83bdd276d6a39f956e Mon Sep 17 00:00:00 2001 From: tktaf Date: Sun, 2 Aug 2026 23:33:02 -0500 Subject: [PATCH] feat(routing): pair agent and command model variants Carry role-specific reasoning variants through OpenCode agents, commands, generated SDK types, beta remediation, and release review. --- .github/workflows/docs-locale-sync.yml | 2 +- .github/workflows/docs-update.yml | 3 +- .github/workflows/opencode.yml | 1 + .opencode/agent/translator.md | 3 +- .opencode/command/changelog.md | 3 +- packages/opencode/src/command/index.ts | 2 + packages/opencode/src/config/config.ts | 1 + packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/test/config/config.test.ts | 2 + packages/opencode/test/config/routing.test.ts | 26 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 150 +++++++++--------- script/beta.ts | 132 +++++++++++++-- script/changelog.ts | 12 +- 13 files changed, 246 insertions(+), 93 deletions(-) create mode 100644 packages/opencode/test/config/routing.test.ts diff --git a/.github/workflows/docs-locale-sync.yml b/.github/workflows/docs-locale-sync.yml index 9689eee6d212..dc42f6b5e83d 100644 --- a/.github/workflows/docs-locale-sync.yml +++ b/.github/workflows/docs-locale-sync.yml @@ -67,7 +67,7 @@ jobs: } } run: | - opencode run --agent docs --model opencode/gpt-5.3-codex <<'EOF' + opencode run --agent docs --model openai/gpt-5.6-terra --variant medium <<'EOF' Update localized docs to match the latest English docs changes. Changed English doc files: diff --git a/.github/workflows/docs-update.yml b/.github/workflows/docs-update.yml index 900ad2b0c586..a9aedb427242 100644 --- a/.github/workflows/docs-update.yml +++ b/.github/workflows/docs-update.yml @@ -47,7 +47,8 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - model: opencode/gpt-5.2 + model: openai/gpt-5.6-terra + variant: medium agent: docs prompt: | Review the following commits from the last ${{ env.LOOKBACK_HOURS }} hours and identify any new features that may need documentation. diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 76e75fcaefb9..11bc109fe18c 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -32,3 +32,4 @@ jobs: OPENCODE_PERMISSION: '{"bash": "deny"}' with: model: opencode/claude-opus-4-5 + variant: high diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md index a987d01927b6..e5e65090aa3a 100644 --- a/.opencode/agent/translator.md +++ b/.opencode/agent/translator.md @@ -1,7 +1,8 @@ --- description: Translate content for a specified locale while preserving technical terms mode: subagent -model: opencode/gpt-5.4 +model: openai/gpt-5.6-luna +variant: low --- You are a professional translator and localization specialist. diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md index 4cd30a704a4a..c46b72f5b006 100644 --- a/.opencode/command/changelog.md +++ b/.opencode/command/changelog.md @@ -1,5 +1,6 @@ --- -model: opencode/gpt-5.4 +model: openai/gpt-5.6-terra +variant: medium --- Create `UPCOMING_CHANGELOG.md` from the structured changelog input below. diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 088d7c565975..4ac819c112bf 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -36,6 +36,7 @@ export namespace Command { description: z.string().optional(), agent: z.string().optional(), model: z.string().optional(), + variant: z.string().optional(), source: z.enum(["command", "mcp", "skill"]).optional(), // workaround for zod not supporting async functions natively so we use getters // https://zod.dev/v4/changelog?id=zfunction @@ -108,6 +109,7 @@ export namespace Command { name, agent: command.agent, model: command.model, + variant: command.variant, description: command.description, source: "command", get template() { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 850bcc28bcd9..29768524022f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -504,6 +504,7 @@ export namespace Config { description: z.string().optional(), agent: z.string().optional(), model: ModelId.optional(), + variant: z.string().optional(), subtask: z.boolean().optional(), }) export type Command = z.infer diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 24996c8d4b29..35d55b0c367d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1686,7 +1686,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the model: userModel, agent: userAgent, parts, - variant: input.variant, + variant: input.variant ?? cmd.variant, }) yield* bus.publish(Command.Event.Executed, { name: input.command, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9c631360b620..87b63faa4f21 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -457,6 +457,7 @@ test("handles command configuration", async () => { template: "test template", description: "test command", agent: "test_agent", + variant: "medium", }, }, }) @@ -470,6 +471,7 @@ test("handles command configuration", async () => { template: "test template", description: "test command", agent: "test_agent", + variant: "medium", }) }, }) diff --git a/packages/opencode/test/config/routing.test.ts b/packages/opencode/test/config/routing.test.ts new file mode 100644 index 000000000000..2e733e99bf93 --- /dev/null +++ b/packages/opencode/test/config/routing.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test" +import path from "path" +import { ConfigMarkdown } from "../../src/config/markdown" + +const root = path.resolve(import.meta.dir, "../../../..") +const routes = [ + [".opencode/agent/translator.md", "openai/gpt-5.6-luna", "low"], + [".opencode/command/changelog.md", "openai/gpt-5.6-terra", "medium"], +] as const + +for (const [file, model, variant] of routes) { + test(`${file} pairs its model with reasoning`, async () => { + const data = (await ConfigMarkdown.parse(path.join(root, file))).data + expect(data.model).toBe(model) + expect(data.variant).toBe(variant) + }) +} + +test("the beta release gate does not request commands from a shell-denied route", async () => { + const source = await Bun.file(path.join(root, "script/beta.ts")).text() + const gate = source.slice(source.indexOf("async function gate"), source.indexOf("async function smoke")) + + expect(gate).toContain("do not run shell commands") + expect(gate).toContain("immediately before this gate") + expect(gate).not.toContain('"Inspect the final diff and run `bun typecheck`') +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d517abf2c6c1..291e433d671b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4,6 +4,74 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } +export type EventServerConnected = { + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + +export type EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + /** + * Duration in milliseconds + */ + duration?: number + } +} + +export type EventTuiSessionSelect = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type Project = { id: string worktree: string @@ -54,20 +122,6 @@ export type EventServerInstanceDisposed = { } } -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -268,60 +322,6 @@ export type EventTodoUpdated = { } } -export type EventTuiPromptAppend = { - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ - duration?: number - } -} - -export type EventTuiSessionSelect = { - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type EventMcpToolsChanged = { type: "mcp.tools.changed" properties: { @@ -964,12 +964,16 @@ export type EventSessionDeleted = { } export type Event = + | EventServerConnected + | EventGlobalDisposed + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect | EventProjectUpdated | EventInstallationUpdated | EventInstallationUpdateAvailable | EventServerInstanceDisposed - | EventServerConnected - | EventGlobalDisposed | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -984,10 +988,6 @@ export type Event = | EventFileEdited | EventFileWatcherUpdated | EventTodoUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -1428,6 +1428,7 @@ export type Config = { description?: string agent?: string model?: string + variant?: string subtask?: boolean } } @@ -2011,6 +2012,7 @@ export type Command = { description?: string agent?: string model?: string + variant?: string source?: "command" | "mcp" | "skill" template: string subtask?: boolean diff --git a/script/beta.ts b/script/beta.ts index 6f4ff4ebf9e8..986de15bcc2e 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -3,7 +3,85 @@ import { $ } from "bun" import fs from "fs/promises" -const model = "opencode/gpt-5.3-codex" +type Role = "implement-complex" | "release-gate" +type Runner = "codex" | "opencode" | "pi" + +interface Route { + launcher: string + may_write: boolean + model: string + reasoning: string + runner: Runner +} + +function valid(value: unknown): value is Runner { + return value === "codex" || value === "opencode" || value === "pi" +} + +async function route(role: Role) { + if (!process.env.HOME) throw new Error("HOME is required to resolve the canonical subagent route") + const result = + (await $`${process.env.HOME}/.codex/scripts/subagent-route.sh --role ${role} --format json --probe`.json()) as Record< + string, + unknown + > + if (result.role !== role || !valid(result.runner) || result.launcher !== "direct") { + throw new Error(`Canonical ${role} route returned an unsupported runner or launcher`) + } + if (typeof result.model !== "string" || typeof result.reasoning !== "string") { + throw new Error("Canonical subagent route did not return a model/reasoning pair") + } + if (typeof result.may_write !== "boolean" || result.may_write !== (role === "implement-complex")) { + throw new Error(`Canonical ${role} route returned an invalid write contract`) + } + return result as Route +} + +async function run(lane: Route, prompt: string, safe = false) { + console.log(` Using ${lane.runner} ${lane.model} (${lane.reasoning})`) + + if (lane.runner === "opencode") { + const env = safe + ? { + ...process.env, + OPENCODE_PERMISSION: JSON.stringify({ + read: "allow", + glob: "allow", + grep: "allow", + list: "allow", + lsp: "allow", + edit: "deny", + bash: "deny", + external_directory: "deny", + task: "deny", + todowrite: "deny", + skill: "deny", + webfetch: "deny", + websearch: "deny", + codesearch: "deny", + }), + } + : process.env + await $`opencode run --model ${lane.model} --variant ${lane.reasoning} ${prompt}`.env(env) + return + } + + if (lane.runner === "pi") { + if (safe) { + await $`pi --model ${lane.model} --thinking ${lane.reasoning} --tools read,grep,find,ls --print ${prompt}` + return + } + await $`pi --model ${lane.model} --thinking ${lane.reasoning} --print ${prompt}` + return + } + + const cfg = `model_reasoning_effort="${lane.reasoning}"` + if (safe) { + await $`codex exec --model ${lane.model} -c ${cfg} --sandbox read-only ${prompt}` + return + } + await $`codex exec --model ${lane.model} -c ${cfg} ${prompt}` +} interface PR { number: number @@ -96,7 +174,7 @@ async function install() { } async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) { - console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`) + console.log(` Trying to auto-resolve ${files.length} conflict(s) with the role-aware runner...`) const done = lines(prs.filter((x) => applied.includes(x.number))) const next = lines(prs.slice(idx + 1)) @@ -122,9 +200,10 @@ async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: n ].join("\n") try { - await $`opencode run -m ${model} ${prompt}` + const lane = await route("implement-complex") + await run(lane, prompt) } catch (err) { - console.log(` opencode failed: ${err}`) + console.log(` Role-aware runner failed: ${err}`) return false } @@ -138,12 +217,44 @@ async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: n if (!(await typecheck())) return false - console.log(" Conflicts resolved with opencode") + console.log(" Conflicts resolved with the role-aware runner") + return true +} + +async function gate(prs: PR[], applied: number[]) { + console.log("\nRunning independent release gate with the canonical route...") + + const done = lines(prs.filter((x) => applied.includes(x.number))) + const changed = await $`git diff --name-status origin/dev...HEAD`.text().catch(() => "(unavailable)") + const prompt = [ + "Review the completed beta merge batch as an independent, read-only release gate.", + `Merged PRs on HEAD:\n${done}`, + `Changed-file manifest from origin/dev...HEAD:\n${changed}`, + "The parent process completed `bun typecheck` and `./script/build.ts --single` in `packages/opencode` immediately before this gate.", + "Inspect the listed merged files with read-only tools and treat those completed commands as verification evidence; do not run shell commands.", + "Report any release-blocking correctness, integration, or data-integrity issue.", + "Do not edit files and do not create a commit.", + ].join("\n") + + try { + const lane = await route("release-gate") + await run(lane, prompt, true) + } catch (err) { + console.log(`Release gate failed: ${err}`) + return false + } + + if ((await $`git status --porcelain`.text()).trim()) { + console.log("Release gate modified the working tree despite its read-only contract") + return false + } + + console.log("Release gate passed") return true } async function smoke(prs: PR[], applied: number[]) { - console.log("\nRunning final smoke check with opencode...") + console.log("\nRunning final smoke remediation with the role-aware runner...") const done = lines(prs.filter((x) => applied.includes(x.number))) const prompt = [ @@ -156,7 +267,8 @@ async function smoke(prs: PR[], applied: number[]) { ].join("\n") try { - await $`opencode run -m ${model} ${prompt}` + const lane = await route("implement-complex") + await run(lane, prompt) } catch (err) { console.log(`Smoke fix failed: ${err}`) return false @@ -172,8 +284,7 @@ async function smoke(prs: PR[], applied: number[]) { const out = await $`git status --porcelain`.text() if (!out.trim()) { - console.log("Smoke check passed") - return true + return gate(prs, applied) } try { @@ -192,8 +303,7 @@ async function smoke(prs: PR[], applied: number[]) { return false } - console.log("Smoke check passed") - return true + return gate(prs, applied) } async function main() { diff --git a/script/changelog.ts b/script/changelog.ts index 971c38c11126..bebcc162e2ea 100755 --- a/script/changelog.ts +++ b/script/changelog.ts @@ -11,7 +11,7 @@ const { values, positionals } = parseArgs({ options: { from: { type: "string", short: "f" }, to: { type: "string", short: "t" }, - variant: { type: "string", default: "low" }, + variant: { type: "string", default: "medium" }, quiet: { type: "boolean", default: false }, print: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, @@ -32,7 +32,7 @@ Generates UPCOMING_CHANGELOG.md by running the opencode changelog command. Options: -f, --from Starting version (default: latest non-draft GitHub release) -t, --to Ending ref (default: HEAD) - --variant Thinking variant for opencode run (default: low) + --variant Thinking variant for opencode run (default: medium) --quiet Suppress opencode command output unless it fails --print Print the generated UPCOMING_CHANGELOG.md after success -h, --help Show this help message @@ -45,11 +45,17 @@ Examples: process.exit(0) } +const variant = values.variant ?? "medium" +if (!["medium", "high"].includes(variant)) { + console.error("--variant must be medium or high for the Terra changelog route") + process.exit(2) +} + await rm(file, { force: true }) const quiet = values.quiet const cmd = ["opencode", "run"] -cmd.push("--variant", values.variant) +cmd.push("--variant", variant) cmd.push("--command", "changelog", "--", ...args) const proc = Bun.spawn(cmd, {