From 459eccafc51e222b67a5e9139f963b5837a976b9 Mon Sep 17 00:00:00 2001 From: Elia <83713217+eliahilse@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:53:39 +0200 Subject: [PATCH] feat: cross-family subagents with model and effort selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent_spawn delegates to another model family without the caller naming one — least-spent capable family wins — and agent_fanout runs distinct tasks in parallel, one per family. Every delegation tool now takes model and effort, applied per engine as CLI flags or the model env var, and council_models lists what each family accepts (Codex reports the model from the user's own config rather than a guess). Adds a chat invocation mode: council answers are free-form, so they must not inherit the review path's --output-schema, which codex rejected as an empty schema. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CQy3ZJ5MyUxo4qjZwA93Na --- packages/council/mcp/README.md | 7 ++ packages/council/mcp/src/assign.ts | 48 +++++++++ packages/council/mcp/src/council.ts | 88 ++++++++++++++++- packages/council/mcp/src/fanout.test.ts | 35 +++++++ packages/council/mcp/src/index.ts | 123 ++++++++++++++++++++++-- packages/review/cli/src/engines.ts | 83 +++++++++++++++- 6 files changed, 367 insertions(+), 17 deletions(-) create mode 100644 packages/council/mcp/src/assign.ts create mode 100644 packages/council/mcp/src/fanout.test.ts diff --git a/packages/council/mcp/README.md b/packages/council/mcp/README.md index e40d43f..805e9e0 100644 --- a/packages/council/mcp/README.md +++ b/packages/council/mcp/README.md @@ -13,8 +13,15 @@ Your agent is one lineage with one set of blind spots. This gives it a way to as | `council_ask` | one specific family's perspective, read-only | | `council_task` | delegate concrete work to one family (`write: true` lets it edit files) | | `council_result` | collect a background council or task by job id | +| `agent_spawn` | spawn a subagent from another family — you don't pick who, the least-spent capable family is chosen | +| `agent_fanout` | several *different* tasks in parallel, one per family | +| `council_models` | which families are summonable, and the models and efforts each accepts | | `council_status` | who can be summoned right now, with remaining quota | +`agent_spawn` is the plain subagent shape: hand it a task, get a worker from a different lineage on a separate subscription. `council_*` is for opinions on one question; `agent_*` is for work. + +Every delegation tool takes optional `model` and `effort`. Models are passed straight through to the vendor CLI, so newer ids work before this package knows about them — `council_models` lists what each family is known to accept (for Codex, whatever your own `~/.codex/config.toml` is set to). + Councils are quota-aware: members are seated highest-remaining-quota first, engines that are cooling down or exhausted are never summoned, and `size` caps a council so one question doesn't spend every subscription. ## Install diff --git a/packages/council/mcp/src/assign.ts b/packages/council/mcp/src/assign.ts new file mode 100644 index 0000000..6b6575b --- /dev/null +++ b/packages/council/mcp/src/assign.ts @@ -0,0 +1,48 @@ +export interface DelegatedTask { + task: string + engine?: string + context?: string + model?: string + effort?: string +} + +export interface Assignment { + task: DelegatedTask + engine: string +} + +/** + * Distribute independent tasks over the engines that currently have quota: + * pinned tasks first, then one family each until families run out, then reuse. + * A task pinned to an unavailable family is reported, never silently rerouted. + */ +export function assignTasks( + tasks: DelegatedTask[], + pool: string[], +): { assignments: Assignment[]; unassigned: string[] } { + const assignments: Assignment[] = [] + const unassigned: string[] = [] + if (pool.length === 0) return { assignments, unassigned: tasks.map((task) => task.task) } + + const taken = new Set() + for (const task of tasks) { + if (!task.engine) continue + if (pool.includes(task.engine)) { + assignments.push({ task, engine: task.engine }) + taken.add(task.engine) + } else { + unassigned.push(task.task) + } + } + + let cursor = 0 + for (const task of tasks) { + if (task.engine) continue + const fresh = pool.filter((engine) => !taken.has(engine)) + const engine = fresh.length > 0 ? fresh[0]! : pool[cursor % pool.length]! + if (fresh.length > 0) taken.add(engine) + assignments.push({ task, engine }) + cursor++ + } + return { assignments, unassigned } +} diff --git a/packages/council/mcp/src/council.ts b/packages/council/mcp/src/council.ts index 63cf0bb..2cf8472 100644 --- a/packages/council/mcp/src/council.ts +++ b/packages/council/mcp/src/council.ts @@ -8,9 +8,13 @@ import { runEngineRaw, type EngineDef, type EngineMode, + type RunOptions, } from "@kyora-sh/review/engines" import { cooldownRemainingMs, lastRunAt, loadUsage } from "@kyora-sh/review/usage" import type { ReviewConfig } from "@kyora-sh/review/types" +import { assignTasks, type DelegatedTask } from "./assign" + +export type { DelegatedTask } export const RUN_CONFIG: ReviewConfig = { engines: ["auto"], @@ -36,6 +40,28 @@ export interface EngineHealth { writeCapable: boolean } +export interface EngineCatalogEntry { + id: string + label: string + available: boolean + models: string[] + defaultModel: string | null + supportsEffort: boolean + writeCapable: boolean +} + +export function engineCatalog(): EngineCatalogEntry[] { + return ENGINES.map((engine) => ({ + id: engine.id, + label: engine.label, + available: engineStatus(engine, undefined).available, + models: engine.models ?? [], + defaultModel: engine.models?.[0] ?? null, + supportsEffort: Boolean(engine.effortArgs), + writeCapable: Boolean(engine.argsWrite), + })) +} + export async function councilStatus(): Promise { const usage = loadUsage() return Promise.all( @@ -56,7 +82,7 @@ export async function councilStatus(): Promise { } /** Engines that can actually be spent right now, cheapest-to-quota first. */ -export async function healthyEngines(requested?: string[], mode: EngineMode = "read"): Promise { +export async function healthyEngines(requested?: string[], mode: EngineMode = "chat"): Promise { const usage = loadUsage() const pool = requested?.length ? requested.map((id) => engineById(id.trim())).filter((engine): engine is EngineDef => Boolean(engine)) @@ -101,6 +127,7 @@ ${task}` export interface EngineReply { engine: string + model?: string ok: boolean text: string durationMs: number @@ -138,11 +165,12 @@ export async function askEngine( engine: EngineDef, prompt: string, cwd: string, - mode: EngineMode = "read", + options: RunOptions = {}, ): Promise { - const run = await runEngineRaw(engine, prompt, {}, cwd, RUN_CONFIG, mode) + const run = await runEngineRaw(engine, prompt, {}, cwd, RUN_CONFIG, { mode: "chat", ...options }) return { engine: engine.id, + ...(options.model ? { model: options.model } : {}), ok: run.ok, text: run.ok ? cleanText(run.raw) : (run.error ?? "failed"), durationMs: run.durationMs, @@ -156,6 +184,7 @@ export async function convene(opts: { engines?: string[] size?: number cwd: string + effort?: string }): Promise<{ replies: EngineReply[]; skipped: string[] }> { const available = await healthyEngines(opts.engines) const size = opts.size && opts.size > 0 ? opts.size : available.length @@ -165,7 +194,11 @@ export async function convene(opts: { ) if (seated.length === 0) return { replies: [], skipped } const prompt = ASK_PROMPT(opts.question, opts.context) - const replies = await Promise.all(seated.map((engine) => askEngine(engine, prompt, opts.cwd))) + const replies = await Promise.all( + seated.map((engine) => + askEngine(engine, prompt, opts.cwd, { mode: "chat", ...(opts.effort ? { effort: opts.effort } : {}) }), + ), + ) return { replies, skipped } } @@ -173,6 +206,53 @@ export function askPrompt(question: string, context?: string): string { return ASK_PROMPT(question, context) } +/** + * Pick a delegate without the caller naming one: honor a preference when that + * engine is actually spendable, otherwise take whoever has the most headroom. + */ +export async function pickEngine(prefer: string | undefined, mode: EngineMode): Promise { + if (prefer) { + const preferred = await healthyEngines([prefer], mode) + if (preferred.length > 0) return preferred[0]! + } + const pool = await healthyEngines(undefined, mode) + return pool[0] ?? null +} + +export interface FanoutResult extends EngineReply { + task: string +} + +/** + * Distinct tasks in parallel across distinct families — a worker pool rather + * than a council. Each task goes to a different engine where supply allows, so + * one subscription does not absorb the whole batch. + */ +export async function fanout(opts: { + tasks: DelegatedTask[] + write?: boolean + cwd: string +}): Promise<{ results: FanoutResult[]; unassigned: string[] }> { + const mode: EngineMode = opts.write ? "write" : "chat" + const pool = await healthyEngines(undefined, mode) + const { assignments, unassigned } = assignTasks( + opts.tasks, + pool.map((engine) => engine.id), + ) + const results = await Promise.all( + assignments.map(async ({ task, engine }) => ({ + task: task.task, + ...(await askEngine( + pool.find((candidate) => candidate.id === engine)!, + TASK_PROMPT(task.task, task.context, Boolean(opts.write)), + opts.cwd, + { mode, ...(task.model ? { model: task.model } : {}), ...(task.effort ? { effort: task.effort } : {}) }, + )), + })), + ) + return { results, unassigned } +} + export function taskPrompt(task: string, context: string | undefined, write: boolean): string { return TASK_PROMPT(task, context, write) } diff --git a/packages/council/mcp/src/fanout.test.ts b/packages/council/mcp/src/fanout.test.ts new file mode 100644 index 0000000..d549ada --- /dev/null +++ b/packages/council/mcp/src/fanout.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { assignTasks } from "./assign" + +const POOL = ["glm", "codex", "grok"] + +describe("assignTasks", () => { + test("spreads unpinned tasks across distinct families", () => { + const { assignments } = assignTasks([{ task: "a" }, { task: "b" }, { task: "c" }], POOL) + expect(assignments.map((item) => item.engine).sort()).toEqual(["codex", "glm", "grok"]) + }) + + test("honors pinned engines and leaves them out of the spread", () => { + const { assignments } = assignTasks([{ task: "a", engine: "grok" }, { task: "b" }], POOL) + expect(assignments.find((item) => item.task.task === "a")!.engine).toBe("grok") + expect(assignments.find((item) => item.task.task === "b")!.engine).not.toBe("grok") + }) + + test("reuses families once every one is taken", () => { + const { assignments } = assignTasks([{ task: "a" }, { task: "b" }, { task: "c" }, { task: "d" }], POOL) + expect(assignments).toHaveLength(4) + expect(new Set(assignments.map((item) => item.engine)).size).toBe(3) + }) + + test("reports tasks pinned to an unavailable family instead of silently rerouting", () => { + const { assignments, unassigned } = assignTasks([{ task: "a", engine: "kimi" }, { task: "b" }], POOL) + expect(unassigned).toEqual(["a"]) + expect(assignments).toHaveLength(1) + }) + + test("assigns nothing when no family has quota", () => { + const { assignments, unassigned } = assignTasks([{ task: "a" }], []) + expect(assignments).toHaveLength(0) + expect(unassigned).toEqual(["a"]) + }) +}) diff --git a/packages/council/mcp/src/index.ts b/packages/council/mcp/src/index.ts index f29f26d..ca428e0 100644 --- a/packages/council/mcp/src/index.ts +++ b/packages/council/mcp/src/index.ts @@ -7,9 +7,12 @@ import { askPrompt, convene, councilStatus, + engineCatalog, + fanout, healthyEngines, loadJob, newJobId, + pickEngine, saveJob, summarizeReplies, taskPrompt, @@ -32,10 +35,11 @@ server.tool( context: z.string().optional().describe("your current reasoning, constraints, and what you are about to do"), engines: z.array(z.string()).optional().describe("engine ids to seat (default: all with quota)"), size: z.number().optional().describe("cap the council to N members, highest remaining quota first"), + effort: z.string().optional().describe("reasoning effort for members that support it"), cwd: z.string().optional().describe("repository path the council should reason inside"), }, - async ({ question, context, engines, size, cwd }) => { - const result = await convene({ question, context, engines, size, cwd: cwdOf(cwd) }) + async ({ question, context, engines, size, effort, cwd }) => { + const result = await convene({ question, context, engines, size, effort, cwd: cwdOf(cwd) }) if (result.replies.length === 0) { return text( `No council member could be seated — every engine is unavailable, cooling down, or out of quota. Skipped: ${result.skipped.join(", ")}. Proceed on your own judgment, and say so.`, @@ -105,14 +109,19 @@ server.tool( engine: z.string().describe("engine id: codex, claude, kimi, glm, grok, qwen"), question: z.string(), context: z.string().optional(), + model: z.string().optional().describe("specific model for that family (see council_models)"), + effort: z.string().optional().describe("reasoning effort where supported"), cwd: z.string().optional(), }, - async ({ engine, question, context, cwd }) => { + async ({ engine, question, context, model, effort, cwd }) => { const def = engineById(engine) if (!def) return text(`Unknown engine "${engine}". Run council_status to see who is available.`) const seated = await healthyEngines([engine]) if (seated.length === 0) return text(`${engine} is unavailable, cooling down, or out of quota right now.`) - const reply = await askEngine(seated[0]!, askPrompt(question, context), cwdOf(cwd)) + const reply = await askEngine(seated[0]!, askPrompt(question, context), cwdOf(cwd), { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + }) return text(reply.ok ? reply.text : `${engine} failed: ${reply.text}`) }, ) @@ -124,12 +133,14 @@ server.tool( engine: z.string(), task: z.string().describe("what to do, stated completely — the delegate has none of your conversation"), context: z.string().optional(), + model: z.string().optional(), + effort: z.string().optional(), write: z.boolean().optional().describe("allow file edits (default false)"), background: z.boolean().optional().describe("return a job id immediately instead of waiting"), cwd: z.string().optional(), }, - async ({ engine, task, context, write, background, cwd }) => { - const mode = write ? ("write" as const) : ("read" as const) + async ({ engine, task, context, model, effort, write, background, cwd }) => { + const mode = write ? ("write" as const) : ("chat" as const) const seated = await healthyEngines([engine], mode) if (seated.length === 0) { const def = engineById(engine) @@ -137,21 +148,117 @@ server.tool( return text(`${engine} is unavailable, cooling down, or out of quota right now.`) } const prompt = taskPrompt(task, context, Boolean(write)) + const runOptions = { mode, ...(model ? { model } : {}), ...(effort ? { effort } : {}) } if (!background) { - const reply = await askEngine(seated[0]!, prompt, cwdOf(cwd), mode) + const reply = await askEngine(seated[0]!, prompt, cwdOf(cwd), runOptions) return text(reply.ok ? reply.text : `${engine} failed: ${reply.text}`) } const started = Date.now() const id = newJobId("task", started) const job: Job = { id, kind: "task", status: "running", question: task, engines: [engine], startedAt: started } saveJob(job) - void askEngine(seated[0]!, prompt, cwdOf(cwd), mode) + void askEngine(seated[0]!, prompt, cwdOf(cwd), runOptions) .then((reply) => saveJob({ ...job, status: "done", finishedAt: Date.now(), replies: [reply] })) .catch((error: unknown) => saveJob({ ...job, status: "failed", finishedAt: Date.now(), error: String(error) })) return text(`Task ${id} delegated to ${engine} in the background. Collect it with council_result.`) }, ) +server.tool( + "agent_spawn", + "Spawn a subagent from another model family to do a piece of work for you — like your own subagents, but a different lineage, on a separate subscription. You do not have to pick who: the least-spent capable family is chosen automatically. Read-only unless write is set. Use for independent workstreams, second implementations, or work better suited to another family.", + { + task: z.string().describe("what to do, stated completely — the subagent has none of your conversation"), + context: z.string().optional().describe("background it needs: constraints, prior decisions, file pointers"), + prefer: z.string().optional().describe("preferred engine id; ignored if that family has no quota"), + model: z.string().optional().describe("specific model for that family (see council_models)"), + effort: z.string().optional().describe("reasoning effort where supported: low, medium, high, max"), + write: z.boolean().optional().describe("allow file edits (default false)"), + background: z.boolean().optional().describe("return a job id immediately instead of waiting"), + cwd: z.string().optional(), + }, + async ({ task, context, prefer, model, effort, write, background, cwd }) => { + const mode = write ? ("write" as const) : ("chat" as const) + const engine = await pickEngine(prefer, mode) + if (!engine) { + return text( + `No model family can be spawned right now — all are unavailable, cooling down, or out of quota${ + mode === "write" ? " (write-capable)" : "" + }. Do the work yourself, or retry later.`, + ) + } + const prompt = taskPrompt(task, context, Boolean(write)) + const runOptions = { mode, ...(model ? { model } : {}), ...(effort ? { effort } : {}) } + const tag = model ? `${engine.id}/${model}` : engine.id + if (!background) { + const reply = await askEngine(engine, prompt, cwdOf(cwd), runOptions) + return text(reply.ok ? `[${tag}]\n\n${reply.text}` : `${tag} failed: ${reply.text}`) + } + const started = Date.now() + const id = newJobId("agent", started) + const job: Job = { id, kind: "task", status: "running", question: task, engines: [engine.id], startedAt: started } + saveJob(job) + void askEngine(engine, prompt, cwdOf(cwd), runOptions) + .then((reply) => saveJob({ ...job, status: "done", finishedAt: Date.now(), replies: [reply] })) + .catch((error: unknown) => saveJob({ ...job, status: "failed", finishedAt: Date.now(), error: String(error) })) + return text(`Spawned ${tag} as subagent ${id} in the background. Collect it with council_result.`) + }, +) + +server.tool( + "agent_fanout", + "Run several DIFFERENT tasks in parallel, each on a different model family. Use to parallelize independent workstreams across subscriptions — not to ask one question many ways (that is council_convene).", + { + tasks: z + .array( + z.object({ + task: z.string(), + engine: z.string().optional().describe("pin this task to a family; otherwise assigned automatically"), + context: z.string().optional(), + model: z.string().optional(), + effort: z.string().optional(), + }), + ) + .describe("independent tasks; each is handled by its own subagent"), + write: z.boolean().optional().describe("allow file edits — only for tasks that touch disjoint files"), + cwd: z.string().optional(), + }, + async ({ tasks, write, cwd }) => { + if (tasks.length === 0) return text("No tasks given.") + const { results, unassigned } = await fanout({ tasks, write, cwd: cwdOf(cwd) }) + if (results.length === 0) return text("No model family had quota to take these tasks.") + const blocks = results.map( + (result) => + `### ${result.engine} — ${result.task.slice(0, 120)}\n\n${result.ok ? result.text : `failed: ${result.text}`}`, + ) + if (unassigned.length > 0) { + blocks.push(`### unassigned\n\n${unassigned.length} task(s) pinned to a family with no quota: ${unassigned.join("; ")}`) + } + return text(blocks.join("\n\n---\n\n")) + }, +) + +server.tool( + "council_models", + "List the model families that can be summoned and the specific models and reasoning efforts each one accepts. Check this before passing model or effort to agent_spawn, council_ask, or council_task.", + {}, + async () => { + const lines = engineCatalog().map((entry) => { + const models = entry.models.length > 0 ? entry.models.join(", ") : "engine default only" + const effort = entry.supportsEffort ? " · effort: low|medium|high|max" : "" + const write = entry.writeCapable ? "" : " · read-only" + return `${entry.available ? "✓" : "✗"} ${entry.id.padEnd(6)} ${models}${effort}${write}` + }) + return text( + [ + ...lines, + "", + "First model listed is the default. Models are passed straight to the vendor CLI, so a newer id it accepts will work even if it is not listed here.", + ].join("\n"), + ) + }, +) + server.tool( "council_status", "Show which model families can be summoned right now, with remaining subscription quota where the vendor exposes it.", diff --git a/packages/review/cli/src/engines.ts b/packages/review/cli/src/engines.ts index 788fc3d..05e7591 100644 --- a/packages/review/cli/src/engines.ts +++ b/packages/review/cli/src/engines.ts @@ -78,14 +78,30 @@ export interface EngineDef { args: string[] /** args for delegated work that may edit files; absent = engine is read-only */ argsWrite?: string[] + /** args for free-form prose answers, without schema-constrained output */ + argsChat?: string[] env?: () => Record /** engine writes its final message to the {out} file instead of stdout */ readsOutFile?: boolean /** live remaining-quota query where the vendor exposes one; null = unavailable */ usageProbe?: () => Promise + /** selectable models; first entry is the default */ + models?: string[] + /** extra args to select a model, when the CLI takes it as a flag */ + modelArgs?: (model: string) => string[] + /** env var carrying the model id, for engines routed through another vendor's CLI */ + modelEnv?: string + /** extra args to set reasoning effort, when supported */ + effortArgs?: (effort: string) => string[] authHint: string } +export interface RunOptions { + mode?: EngineMode + model?: string + effort?: string +} + async function qwenUsageProbe(): Promise { const cookie = process.env.QWEN_USAGE_COOKIE if (!cookie) return null @@ -103,6 +119,15 @@ async function qwenUsageProbe(): Promise { } } +function codexConfiguredModel(): string | undefined { + try { + const config = readFileSync(join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "config.toml"), "utf8") + return /^\s*model\s*=\s*"([^"]+)"/m.exec(config)?.[1] + } catch { + return undefined + } +} + function bailianKey(): string | undefined { if (process.env.QWEN_API_KEY) return process.env.QWEN_API_KEY try { @@ -166,7 +191,7 @@ const CLAUDE_WRITE_ARGS = [ CLAUDE_WRITE_DENIED, ] -export type EngineMode = "read" | "write" +export type EngineMode = "read" | "write" | "chat" export const ENGINES: EngineDef[] = [ { @@ -175,7 +200,11 @@ export const ENGINES: EngineDef[] = [ bin: "codex", args: ["exec", "--sandbox", "read-only", "--output-schema", "{schema}", "-o", "{out}", "{prompt}"], argsWrite: ["exec", "--sandbox", "workspace-write", "--full-auto", "{prompt}"], + argsChat: ["exec", "--sandbox", "read-only", "{prompt}"], readsOutFile: true, + models: codexConfiguredModel() ? [codexConfiguredModel()!] : [], + modelArgs: (model) => ["-m", model], + effortArgs: (effort) => ["-c", `model_reasoning_effort="${effort}"`], authHint: "run `codex login` (ChatGPT subscription) or set OPENAI_API_KEY — CI: seed the CODEX_AUTH_JSON secret", }, { @@ -185,6 +214,8 @@ export const ENGINES: EngineDef[] = [ args: CLAUDE_ARGS, argsWrite: CLAUDE_WRITE_ARGS, usageProbe: claudeUsageProbe, + models: ["sonnet", "opus", "haiku"], + modelArgs: (model) => ["--model", model], authHint: "log in once via `claude`, or set CLAUDE_CODE_OAUTH_TOKEN (created with `claude setup-token`)", }, { @@ -201,6 +232,8 @@ export const ENGINES: EngineDef[] = [ }), argsWrite: CLAUDE_WRITE_ARGS, usageProbe: kimiUsageProbe, + models: ["kimi-k3"], + modelEnv: "ANTHROPIC_MODEL", authHint: "set KIMI_API_KEY (Kimi membership / platform.kimi.ai); optional KIMI_BASE_URL, KIMI_MODEL", }, { @@ -217,6 +250,8 @@ export const ENGINES: EngineDef[] = [ }), argsWrite: CLAUDE_WRITE_ARGS, usageProbe: glmUsageProbe, + models: ["glm-5.2", "glm-5.2[1m]"], + modelEnv: "ANTHROPIC_MODEL", authHint: "set ZAI_API_KEY (GLM Coding Plan), or log in once via `opencode auth login` — the key is picked up from there", }, { @@ -225,6 +260,10 @@ export const ENGINES: EngineDef[] = [ bin: "grok", args: ["--verbatim", "--reasoning-effort", "high", "--output-format", "json", "--json-schema", "{schemaJson}", "-p", "{prompt}"], argsWrite: ["--verbatim", "--reasoning-effort", "high", "--always-approve", "-p", "{prompt}"], + argsChat: ["--verbatim", "--reasoning-effort", "high", "-p", "{prompt}"], + models: ["grok-4.5"], + modelArgs: (model) => ["-m", model], + effortArgs: (effort) => ["--reasoning-effort", effort], authHint: "log in via `grok login`, or set GROK_API_KEY / XAI_API_KEY (console.x.ai)", }, { @@ -242,6 +281,8 @@ export const ENGINES: EngineDef[] = [ }), argsWrite: CLAUDE_WRITE_ARGS, usageProbe: qwenUsageProbe, + models: ["qwen3.8-max-preview"], + modelEnv: "ANTHROPIC_MODEL", authHint: "set QWEN_API_KEY (Token Plan key), or run `bl config agent` once — the key is picked up from there", }, ] @@ -279,6 +320,22 @@ export interface RawRun { durationMs: number } +/** A caller-supplied effort flag must replace the engine's built-in one, not duplicate it. */ +function dedupeEffort(selectors: string[], template: string[]): string[] { + if (selectors.length === 0) return template + const flags = new Set(selectors.filter((token) => token.startsWith("-"))) + const kept: string[] = [] + for (let i = 0; i < template.length; i++) { + const token = template[i]! + if (flags.has(token)) { + i++ + continue + } + kept.push(token) + } + return [...selectors, ...kept] +} + /** Run an engine CLI headless in the repo checkout and capture whatever it printed. */ export async function runEngineRaw( engine: EngineDef, @@ -286,8 +343,9 @@ export async function runEngineRaw( schema: unknown, cwd: string, config: ReviewConfig, - mode: EngineMode = "read", + options: RunOptions = {}, ): Promise { + const mode = options.mode ?? "read" const override = config.overrides[engine.id] const started = Date.now() const workDir = await mkdtemp(join(tmpdir(), `kyora-review-${engine.id}-`)) @@ -297,8 +355,16 @@ export async function runEngineRaw( await Bun.write(schemaPath, JSON.stringify(schema)) const bin = override?.bin ?? engine.bin - const argTemplate = - mode === "write" ? (engine.argsWrite ?? override?.args ?? engine.args) : (override?.args ?? engine.args) + const baseTemplate = + mode === "write" + ? (engine.argsWrite ?? override?.args ?? engine.args) + : mode === "chat" + ? (engine.argsChat ?? override?.args ?? engine.args) + : (override?.args ?? engine.args) + const selectors: string[] = [] + if (options.model && engine.modelArgs) selectors.push(...engine.modelArgs(options.model)) + if (options.effort && engine.effortArgs) selectors.push(...engine.effortArgs(options.effort)) + const argTemplate = dedupeEffort(selectors, baseTemplate) const args = argTemplate.map((arg) => arg .replace("{schemaJson}", () => JSON.stringify(schema)) @@ -307,8 +373,15 @@ export async function runEngineRaw( .replace("{prompt}", () => prompt), ) + const modelOverride = + options.model && engine.modelEnv ? { [engine.modelEnv]: options.model } : {} const env: Record = {} - for (const [key, value] of Object.entries({ ...process.env, ...engine.env?.(), ...override?.env })) { + for (const [key, value] of Object.entries({ + ...process.env, + ...engine.env?.(), + ...override?.env, + ...modelOverride, + })) { if (value !== undefined) env[key] = value }