Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/council/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions packages/council/mcp/src/assign.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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 }
}
88 changes: 84 additions & 4 deletions packages/council/mcp/src/council.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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<EngineHealth[]> {
const usage = loadUsage()
return Promise.all(
Expand All @@ -56,7 +82,7 @@ export async function councilStatus(): Promise<EngineHealth[]> {
}

/** Engines that can actually be spent right now, cheapest-to-quota first. */
export async function healthyEngines(requested?: string[], mode: EngineMode = "read"): Promise<EngineDef[]> {
export async function healthyEngines(requested?: string[], mode: EngineMode = "chat"): Promise<EngineDef[]> {
const usage = loadUsage()
const pool = requested?.length
? requested.map((id) => engineById(id.trim())).filter((engine): engine is EngineDef => Boolean(engine))
Expand Down Expand Up @@ -101,6 +127,7 @@ ${task}`

export interface EngineReply {
engine: string
model?: string
ok: boolean
text: string
durationMs: number
Expand Down Expand Up @@ -138,11 +165,12 @@ export async function askEngine(
engine: EngineDef,
prompt: string,
cwd: string,
mode: EngineMode = "read",
options: RunOptions = {},
): Promise<EngineReply> {
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,
Expand All @@ -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
Expand All @@ -165,14 +194,65 @@ 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 }
}

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<EngineDef | null> {
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)
}
Expand Down
35 changes: 35 additions & 0 deletions packages/council/mcp/src/fanout.test.ts
Original file line number Diff line number Diff line change
@@ -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"])
})
})
Loading
Loading