diff --git a/CONTRACTS.md b/CONTRACTS.md index a53a75d..306e6ff 100644 --- a/CONTRACTS.md +++ b/CONTRACTS.md @@ -75,6 +75,17 @@ node "$OPC_HARNESS" synthesize --node [--run N] # → { verdict: string, findings: object, ... } ``` +### Model Routing Command + +```bash +# Resolve an explicit host-native model before one subagent dispatch +node "$OPC_HARNESS" model-route --node --node-type [--role ] [--dir ] [--allow-premium] +# → { ok: true, dispatch: bool, tier: string, model: string|null, source: string, premium: bool, ... } +# Policy denial → { ok: false, error: { code: string, message: string, details: object } }, exit code 2 +``` + +The resolver reads `agentRouting` through the existing user/repository layered config. `dispatch: false` means the node is orchestrator/tool-only. Hosts MUST pass a successful route's `model` through their explicit per-dispatch selector; they MUST NOT silently inherit the root model when routing fails or cannot be honored. See [`pipeline/model-routing.md`](pipeline/model-routing.md). + ### Loop Commands ```bash diff --git a/README.md b/README.md index 54d8af9..1fe2ef7 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,32 @@ The system is built on a zero-trust axiom: **every critical output must have an - **L2 — Design Agent Flow**: Multi-agent coordination — separation of concerns, flow topology (parallel review / sequential build), context isolation (file-based handoff, fresh agents, no session reuse). - **L3 — Deterministic Enforcement**: The only layer that doesn't need LLM compliance — mechanical ops (severity counting, verdict rules, oscillation diff) and hardened verification (file-finding evidence checks, hedging scans, ref validation). +#### Model-aware dispatch + +OPC preserves its full multi-agent topology while avoiding accidental premium-model fan-out. Before every Agent call, `opc-harness model-route` resolves an explicit host-native model from layered configuration: + +| Tier | Claude Code default | Typical work | +|---|---|---| +| economy | `haiku` | test design, user/UX observers | +| standard | `sonnet` | discussion, implementation, semantic review | +| premium | `inherit` | explicit escalation only | + +`inherit` and `opus` require explicit premium approval. Claude Code's `CLAUDE_CODE_SUBAGENT_MODEL` environment override is detected and reported. Other hosts can replace the tier values with their own model IDs: + +```json +{ + "agentRouting": { + "models": { + "economy": "your-fast-model-id", + "standard": "your-value-model-id", + "premium": "your-premium-model-id" + } + } +} +``` + +See [`pipeline/model-routing.md`](pipeline/model-routing.md) for node/role overrides and the resolver contract. + ### Quick Start #### Install @@ -354,6 +380,20 @@ Task → Flow Selection → Node Execution → Gate Verdict → Route Next - **L2 —— 设计 agent 流**:多 agent 协同——关注点分离、流拓扑(并行 review / 串行 build)、上下文隔离(基于文件的交接、全新 agent、不复用 session)。 - **L3 —— 确定性强制**:唯一不需要 LLM 配合的一层——机械操作(严重度计数、裁决规则、震荡 diff)和加固验证(找文件证据检查、模糊措辞扫描、引用校验)。 +#### 模型感知派发 + +OPC 保留完整的多 agent 拓扑,同时避免顶级模型被意外放大到所有子 agent。每次调用 Agent 前,`opc-harness model-route` 都会从分层配置中解析一个明确的宿主原生模型: + +| 层级 | Claude Code 默认值 | 典型工作 | +|---|---|---| +| economy | `haiku` | 测试设计、用户/UX observer | +| standard | `sonnet` | 讨论、实现、语义审查 | +| premium | `inherit` | 仅显式升级 | + +`inherit` 和 `opus` 需要显式批准。Claude Code 的 `CLAUDE_CODE_SUBAGENT_MODEL` 环境变量覆盖会被检测并展示。其他宿主可以在 `agentRouting.models` 中替换成自己的模型 ID,而无需修改 OPC 的 flow。 + +完整的节点/角色覆盖规则见 [`pipeline/model-routing.md`](pipeline/model-routing.md)。 + ### 快速开始 #### 安装 diff --git a/SKILL.md b/SKILL.md index 8316968..89307f8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -189,6 +189,16 @@ opc-harness viz --flow {TEMPLATE} Before starting, extract **acceptance criteria** — 3-7 concrete, testable bullet points. Evaluators grade against these. +### Agent Model Routing — Mandatory Pre-Flight + +Flow topology and model tier are independent. Keep every selected node, round, and role, but before each Agent call run: + +```bash +opc-harness model-route --node {NODE_ID} --node-type {NODE_TYPE} [--role {ROLE}] --dir {PROJECT_ROOT} +``` + +Pass the returned `model` through the host's per-dispatch selector. `dispatch: false` means no Agent. On an error or unsupported selector, stop instead of silently inheriting the root model. Re-run with `--allow-premium` only after explicit user approval. Show `💰 Model route: {tier} → {model} ({source})` before launch. Read `./pipeline/model-routing.md` only for configuration or troubleshooting. + ### Quality Tier Selection — Mandatory Pre-Flight Before the Definition of Done questions, the orchestrator MUST select a **quality tier**. See `./pipeline/quality-tiers.md` for full definitions. @@ -356,8 +366,8 @@ The orchestrator searches for role definitions in this order (later sources over Show role selection: ``` 📋 Agents: -- frontend — -- security — +- frontend [standard/sonnet] — +- tester [economy/haiku] — ... Launching {N} agents... @@ -396,7 +406,7 @@ The orchestrator uses **cursor-based execution** — `flow-state.json.currentNod Follow `./pipeline/discussion-protocol.md`. -1. Dispatch agents for 3 rounds. **Round 1: parallel** (agents are independent — no reason to serialize). Round 2: serial with context injection (each agent sees Round 1 outputs, writes diffs only). Round 3: facilitator convergence. +1. Resolve a model route for every participant, then dispatch agents for 3 rounds. **Round 1: parallel** (agents are independent — no reason to serialize). Round 2: serial with context injection (each agent sees Round 1 outputs, writes diffs only). Round 3: facilitator convergence. 2. **Orchestrator writes handshake.json** after collecting all artifacts (agents don't write it). 3. Discussion nodes produce no verdict — the decision artifact feeds downstream. @@ -404,7 +414,7 @@ Follow `./pipeline/discussion-protocol.md`. Follow `./pipeline/implementer-prompt.md` in Build/Fix/Polish mode. -1. Dispatch implementer subagent. +1. Resolve the implementer's model route and dispatch it with the returned explicit model. 2. **Single agent** → agent writes its own handshake.json. 3. **Multiple agents** (parallel, with `isolation: "worktree"`) → orchestrator merges artifacts and writes handshake.json. 4. With superpowers: invoke `superpowers:subagent-driven-development`. @@ -414,7 +424,7 @@ Follow `./pipeline/implementer-prompt.md` in Build/Fix/Polish mode. Follow `./pipeline/role-evaluator-prompt.md`. 1. Select roles per Role Selection rules. -2. Dispatch evaluators — parallel if no dependencies, serial with context injection if dependencies exist. +2. Resolve a model route for each selected role, then dispatch evaluators — parallel if no dependencies, serial with context injection if dependencies exist. 3. Each agent writes `eval-{role}.md` to `$SESSION_DIR/nodes/{NODE_ID}/run_{RUN}/`. 4. **Orchestrator writes handshake.json** after all agents return, merging all eval files into artifacts[]. 5. Before dispatching, build context brief using `./pipeline/context-brief.md` (for review/analysis tasks). @@ -516,6 +526,7 @@ All templates live in `./pipeline/`: - `loop-protocol.md` — **Autonomous multi-unit execution** (plan decomposition → cron loop → auto-terminate) - `handoff-template.md` — Handshake.json specification - `context-brief.md` — Design context brief procedure +- `model-routing.md` — Explicit per-node/per-role model selection and premium approval - `report-format.md` — Presentation templates + JSON schema + replay - `quality-tiers.md` — Tier definitions + baseline checklists + severity calibration - `ux-simulation-protocol.md` — **UX simulation gate** (red flag detection, delta comparison, ordinal tier fit) @@ -655,7 +666,7 @@ If hooks are not installed, the fallback behavior is: flow-state.json persists o When the flow completes (route returns `next=null`): 1. Show final viz: `opc-harness viz --flow {template}` -2. Show summary: total steps, nodes visited, any loopbacks +2. Show summary: total steps, nodes visited, any loopbacks, and dispatched agent counts by model tier 3. **Generate HTML report** (use the session dir from init output, or find it via `opc-harness ls`): ```bash node "$OPC_HARNESS/../opc-report.mjs" --dir --output /report.html --title "{task summary}" diff --git a/bin/lib/model-routing.mjs b/bin/lib/model-routing.mjs new file mode 100644 index 0000000..77898d8 --- /dev/null +++ b/bin/lib/model-routing.mjs @@ -0,0 +1,232 @@ +// model-routing.mjs — deterministic model selection for every OPC subagent. +// +// Claude Code defaults an omitted subagent model to the parent model. That is +// convenient, but a multi-agent flow can accidentally multiply premium-model +// usage. This resolver keeps flow/role topology unchanged while making the +// model choice explicit, inspectable, and configurable. + +import { loadLayeredOpcConfig } from "./config-layering.mjs"; +import { getFlag } from "./util.mjs"; + +export const DEFAULT_AGENT_ROUTING = Object.freeze({ + defaultTier: "standard", + unknownModelPolicy: "deny", + allowPremiumByDefault: false, + models: Object.freeze({ + economy: "haiku", + standard: "sonnet", + premium: "inherit", + }), + nodeTypes: Object.freeze({ + discussion: "standard", + brief: "standard", + build: "standard", + review: "standard", + execute: "none", + hotfix: "standard", + gate: "none", + }), + nodes: Object.freeze({ + "test-design": "economy", + "e2e-user": "economy", + "ux-simulation": "economy", + "post-launch-sim": "economy", + }), + roles: Object.freeze({ + tester: "economy", + "new-user": "economy", + "active-user": "economy", + "churned-user": "economy", + "user-simulator": "economy", + }), + premiumModels: Object.freeze(["inherit", "opus"]), +}); + +export class ModelRoutingError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = "ModelRoutingError"; + this.code = code; + this.details = details; + } +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function mergeRecord(base, override) { + return { ...base, ...(isPlainObject(override) ? override : {}) }; +} + +export function normalizeAgentRouting(raw = {}) { + const cfg = isPlainObject(raw) ? raw : {}; + return { + ...DEFAULT_AGENT_ROUTING, + ...cfg, + models: mergeRecord(DEFAULT_AGENT_ROUTING.models, cfg.models), + nodeTypes: mergeRecord(DEFAULT_AGENT_ROUTING.nodeTypes, cfg.nodeTypes), + nodes: mergeRecord(DEFAULT_AGENT_ROUTING.nodes, cfg.nodes), + roles: mergeRecord(DEFAULT_AGENT_ROUTING.roles, cfg.roles), + premiumModels: Array.isArray(cfg.premiumModels) + ? cfg.premiumModels.filter(v => typeof v === "string" && v.trim()) + : [...DEFAULT_AGENT_ROUTING.premiumModels], + }; +} + +function validateIdentifier(value, label) { + if (typeof value !== "string" || !value.trim()) { + throw new ModelRoutingError("INVALID_ROUTE_INPUT", `${label} must be a non-empty string`, { [label]: value }); + } + const trimmed = value.trim(); + if (trimmed.length > 200 || /[\u0000-\u001f\u007f]/.test(trimmed)) { + throw new ModelRoutingError("INVALID_ROUTE_INPUT", `${label} contains invalid characters or is too long`, { [label]: value }); + } + return trimmed; +} + +function resolveTier(routing, { node, nodeType, role }) { + if (role && Object.hasOwn(routing.roles, role)) { + return { tier: routing.roles[role], source: `role:${role}` }; + } + if (Object.hasOwn(routing.nodes, node)) { + return { tier: routing.nodes[node], source: `node:${node}` }; + } + if (Object.hasOwn(routing.nodeTypes, nodeType)) { + return { tier: routing.nodeTypes[nodeType], source: `nodeType:${nodeType}` }; + } + return { tier: routing.defaultTier, source: "defaultTier" }; +} + +function modelIsPremium(model, tier, routing) { + if (tier === "premium") return true; + const lower = model.toLowerCase(); + if (lower.includes("opus")) return true; + return routing.premiumModels.some(item => item.toLowerCase() === lower); +} + +/** + * Resolve a route without reading the filesystem. Useful for tests and hosts + * that already have a merged config object. + */ +export function resolveModelRoute({ + node, + nodeType, + role = null, + config = {}, + env = process.env, + allowPremium = false, +}) { + const safeNode = validateIdentifier(node, "node"); + const safeNodeType = validateIdentifier(nodeType, "nodeType"); + const safeRole = role == null ? null : validateIdentifier(role, "role"); + const routing = normalizeAgentRouting(config.agentRouting); + const { tier: rawTier, source } = resolveTier(routing, { + node: safeNode, + nodeType: safeNodeType, + role: safeRole, + }); + const tier = validateIdentifier(rawTier, "tier"); + + if (tier === "none") { + return { + ok: true, + dispatch: false, + node: safeNode, + nodeType: safeNodeType, + role: safeRole, + tier, + model: null, + source, + configSource: config._source?.agentRouting || "default", + envOverride: false, + premium: false, + premiumApproved: false, + warnings: [], + }; + } + + let model = routing.models[tier]; + if ((model == null || model === "") && routing.unknownModelPolicy === "inherit") { + model = "inherit"; + } + if (model == null || model === "") { + throw new ModelRoutingError( + "MODEL_UNRESOLVED", + `no model configured for tier '${tier}'`, + { tier, source, unknownModelPolicy: routing.unknownModelPolicy }, + ); + } + model = validateIdentifier(model, "model"); + + const envModel = typeof env.CLAUDE_CODE_SUBAGENT_MODEL === "string" + ? env.CLAUDE_CODE_SUBAGENT_MODEL.trim() + : ""; + const warnings = []; + let effectiveSource = source; + let envOverride = false; + if (envModel) { + model = validateIdentifier(envModel, "CLAUDE_CODE_SUBAGENT_MODEL"); + effectiveSource = "env:CLAUDE_CODE_SUBAGENT_MODEL"; + envOverride = true; + warnings.push("CLAUDE_CODE_SUBAGENT_MODEL overrides OPC per-dispatch model selection"); + } + + const premium = modelIsPremium(model, tier, routing); + const premiumApproved = Boolean(allowPremium || routing.allowPremiumByDefault === true || envOverride); + if (premium && !premiumApproved) { + throw new ModelRoutingError( + "PREMIUM_APPROVAL_REQUIRED", + `model '${model}' requires explicit premium approval`, + { tier, model, source: effectiveSource }, + ); + } + + return { + ok: true, + dispatch: true, + node: safeNode, + nodeType: safeNodeType, + role: safeRole, + tier, + model, + source: effectiveSource, + configSource: config._source?.agentRouting || "default", + envOverride, + premium, + premiumApproved, + warnings, + }; +} + +export function cmdModelRoute(args) { + if (args.includes("--help") || args.includes("-h")) { + console.error("Usage: opc-harness model-route --node --node-type [--role ] [--dir ] [--allow-premium]"); + console.error("Resolves the explicit host-native model for one subagent dispatch."); + return; + } + + const node = getFlag(args, "node"); + const nodeType = getFlag(args, "node-type"); + const role = getFlag(args, "role"); + const anchorDir = getFlag(args, "dir", process.cwd()); + const config = loadLayeredOpcConfig(anchorDir, {}); + + try { + const route = resolveModelRoute({ + node, + nodeType, + role, + config, + allowPremium: args.includes("--allow-premium"), + }); + console.log(JSON.stringify(route, null, 2)); + } catch (err) { + if (!(err instanceof ModelRoutingError)) throw err; + console.log(JSON.stringify({ + ok: false, + error: { code: err.code, message: err.message, details: err.details }, + }, null, 2)); + process.exitCode = 2; + } +} diff --git a/bin/lib/model-routing.test.mjs b/bin/lib/model-routing.test.mjs new file mode 100644 index 0000000..d5a82ca --- /dev/null +++ b/bin/lib/model-routing.test.mjs @@ -0,0 +1,221 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + ModelRoutingError, + normalizeAgentRouting, + resolveModelRoute, +} from "./model-routing.mjs"; + +const HARNESS = join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs"); + +function route(overrides = {}) { + return resolveModelRoute({ + node: "code-review", + nodeType: "review", + config: {}, + env: {}, + ...overrides, + }); +} + +describe("model routing defaults", () => { + test("standard nodes use sonnet instead of inheriting the parent", () => { + const out = route(); + assert.equal(out.dispatch, true); + assert.equal(out.tier, "standard"); + assert.equal(out.model, "sonnet"); + assert.equal(out.source, "nodeType:review"); + assert.equal(out.premium, false); + }); + + test("economy nodes use haiku", () => { + const out = route({ node: "test-design", role: null }); + assert.equal(out.tier, "economy"); + assert.equal(out.model, "haiku"); + assert.equal(out.source, "node:test-design"); + }); + + test("economy role override wins over a standard node", () => { + const out = route({ role: "tester" }); + assert.equal(out.tier, "economy"); + assert.equal(out.model, "haiku"); + assert.equal(out.source, "role:tester"); + }); + + test("execute and gate nodes do not dispatch a model", () => { + const out = route({ node: "gate", nodeType: "gate" }); + assert.equal(out.dispatch, false); + assert.equal(out.tier, "none"); + assert.equal(out.model, null); + }); +}); + +describe("model routing configuration", () => { + test("host-native model IDs can replace tier defaults", () => { + const out = route({ + config: { + agentRouting: { + models: { economy: "host-fast", standard: "host-value" }, + roles: { security: "standard" }, + }, + _source: { agentRouting: "repo" }, + }, + role: "security", + }); + assert.equal(out.model, "host-value"); + assert.equal(out.configSource, "repo"); + }); + + test("node override wins over node type", () => { + const out = route({ + config: { agentRouting: { nodes: { "code-review": "economy" } } }, + }); + assert.equal(out.tier, "economy"); + assert.equal(out.source, "node:code-review"); + }); + + test("normalization does not discard unspecified defaults", () => { + const cfg = normalizeAgentRouting({ models: { economy: "fast-v2" } }); + assert.equal(cfg.models.economy, "fast-v2"); + assert.equal(cfg.models.standard, "sonnet"); + assert.equal(cfg.nodeTypes.review, "standard"); + }); + + test("missing configured tier fails closed", () => { + assert.throws( + () => route({ config: { agentRouting: { roles: { security: "unmapped" } } }, role: "security" }), + err => err instanceof ModelRoutingError && err.code === "MODEL_UNRESOLVED", + ); + }); +}); + +describe("premium and runtime overrides", () => { + test("inherit requires explicit premium approval", () => { + assert.throws( + () => route({ config: { agentRouting: { nodeTypes: { review: "premium" } } } }), + err => err instanceof ModelRoutingError && err.code === "PREMIUM_APPROVAL_REQUIRED", + ); + }); + + test("premium route succeeds after explicit approval", () => { + const out = route({ + config: { agentRouting: { nodeTypes: { review: "premium" } } }, + allowPremium: true, + }); + assert.equal(out.model, "inherit"); + assert.equal(out.premium, true); + assert.equal(out.premiumApproved, true); + }); + + test("a string value does not accidentally approve premium use", () => { + assert.throws( + () => route({ + config: { + agentRouting: { + nodeTypes: { review: "premium" }, + allowPremiumByDefault: "false", + }, + }, + }), + err => err instanceof ModelRoutingError && err.code === "PREMIUM_APPROVAL_REQUIRED", + ); + }); + + test("Claude Code environment override is reported and honored", () => { + const out = route({ env: { CLAUDE_CODE_SUBAGENT_MODEL: "custom-value-model" } }); + assert.equal(out.model, "custom-value-model"); + assert.equal(out.envOverride, true); + assert.equal(out.source, "env:CLAUDE_CODE_SUBAGENT_MODEL"); + assert.equal(out.warnings.length, 1); + }); + + test("an explicit premium environment override is treated as user approval", () => { + const out = route({ env: { CLAUDE_CODE_SUBAGENT_MODEL: "opus" } }); + assert.equal(out.model, "opus"); + assert.equal(out.premium, true); + assert.equal(out.premiumApproved, true); + }); +}); + +describe("model-route CLI", () => { + test("loads repository config and prints the chosen host-native model", () => { + const root = mkdtempSync(join(tmpdir(), "opc-model-route-")); + const home = join(root, "home"); + const project = join(root, "project"); + mkdirSync(join(project, ".opc"), { recursive: true }); + mkdirSync(home, { recursive: true }); + writeFileSync(join(project, ".opc", "config.json"), JSON.stringify({ + agentRouting: { models: { economy: "host-fast-model" } }, + })); + const { CLAUDE_CODE_SUBAGENT_MODEL: _ignored, ...cleanEnv } = process.env; + + try { + const result = spawnSync(process.execPath, [ + HARNESS, + "model-route", + "--node", "test-design", + "--node-type", "review", + "--dir", project, + ], { + cwd: project, + encoding: "utf8", + env: { ...cleanEnv, HOME: home }, + }); + assert.equal(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout); + assert.equal(output.model, "host-fast-model"); + assert.equal(output.configSource, "repo"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("returns structured JSON and exit code 2 for denied premium routing", () => { + const root = mkdtempSync(join(tmpdir(), "opc-model-route-")); + const { CLAUDE_CODE_SUBAGENT_MODEL: _ignored, ...cleanEnv } = process.env; + + try { + const result = spawnSync(process.execPath, [ + HARNESS, + "model-route", + "--node", "architecture-review", + "--node-type", "review", + "--role", "architect", + "--dir", root, + ], { + cwd: root, + encoding: "utf8", + env: { ...cleanEnv, HOME: root }, + }); + assert.equal(result.status, 0, result.stderr); + + mkdirSync(join(root, ".opc"), { recursive: true }); + writeFileSync(join(root, ".opc", "config.json"), JSON.stringify({ + agentRouting: { nodeTypes: { review: "premium" } }, + })); + const denied = spawnSync(process.execPath, [ + HARNESS, + "model-route", + "--node", "architecture-review", + "--node-type", "review", + "--dir", root, + ], { + cwd: root, + encoding: "utf8", + env: { ...cleanEnv, HOME: root }, + }); + assert.equal(denied.status, 2); + const output = JSON.parse(denied.stdout); + assert.equal(output.ok, false); + assert.equal(output.error.code, "PREMIUM_APPROVAL_REQUIRED"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/bin/opc-harness.mjs b/bin/opc-harness.mjs index b1498a6..659c195 100755 --- a/bin/opc-harness.mjs +++ b/bin/opc-harness.mjs @@ -9,6 +9,7 @@ import { cmdRoute, cmdInit, cmdValidate, cmdValidateContext, cmdSeal } from "./l import { cmdTransition, cmdValidateChain, cmdFinalize, cmdAdvance } from "./lib/flow-transition.mjs"; import { cmdPromptContext, cmdExtensionTest, cmdExtensionVerdict, cmdExtensionArtifact, cmdNodePreflight } from "./lib/ext-commands.mjs"; import { cmdConfigResolve } from "./lib/config-layering.mjs"; +import { cmdModelRoute } from "./lib/model-routing.mjs"; import { cmdSkip, cmdPass, cmdStop, cmdGoto, cmdLs } from "./lib/flow-escape.mjs"; import { cmdGc } from "./lib/util.mjs"; import { cmdInitLoop } from "./lib/loop-init.mjs"; @@ -62,6 +63,7 @@ switch (command) { case "extension-artifact": await cmdExtensionArtifact(args); break; case "node-preflight": await cmdNodePreflight(args); break; case "config": await cmdConfigResolve(args); break; + case "model-route": cmdModelRoute(args); break; case "runbook": cmdRunbook(args); break; case "clean": cmdClean(args); break; case "gc": cmdGc(args); break; @@ -111,6 +113,8 @@ switch (command) { console.log(); console.log("Config commands:"); console.log(" config resolve [--dir

] Print merged OPC config w/ _source map"); + console.log(" model-route --node --node-type [--role ] [--dir ] [--allow-premium]"); + console.log(" Resolve explicit model for one subagent dispatch"); console.log(); console.log("Runbook commands:"); console.log(" runbook list [--dir

] List all runbooks"); diff --git a/pipeline/discussion-protocol.md b/pipeline/discussion-protocol.md index f139bef..00ebcde 100644 --- a/pipeline/discussion-protocol.md +++ b/pipeline/discussion-protocol.md @@ -13,6 +13,8 @@ You are participating in a multi-agent discussion. Your goal is to reach a well- Round-robin discussion, maximum 3 rounds. Each round builds on the previous. +Before each participant is dispatched, run `opc-harness model-route --node {NODE_ID} --node-type discussion --role {ROLE} --dir {WORKING_DIR}` and pass the returned explicit `model` to the Agent tool. Keep all participants and rounds; routing changes models, not discussion topology. + ### Round 1 — Independent Analysis Each agent is dispatched serially (not parallel). Each agent reads the original task and upstream context, then outputs their independent analysis and proposed approach. diff --git a/pipeline/evaluator-prompt.md b/pipeline/evaluator-prompt.md index 1cb55ad..2ce6c88 100644 --- a/pipeline/evaluator-prompt.md +++ b/pipeline/evaluator-prompt.md @@ -2,7 +2,7 @@ **Orchestrator instructions (do not include in the subagent prompt):** -Read this section, fill in the `{placeholders}` in the subagent prompt below, then pass everything from the `---` separator onward as the `prompt` parameter to the Agent tool with `subagent_type: "general-purpose"`. Strip this header section — the subagent should only see what's below the line. +Read this section and fill in the `{placeholders}`. Before dispatch, run `opc-harness model-route --node {NODE_ID} --node-type review --role evaluator --dir {PROJECT_ROOT}`. Pass everything from the `---` separator onward as the Agent `prompt`, use `subagent_type: "general-purpose"`, and pass the returned route's `model` as the Agent model parameter. If routing fails or the host cannot honor the model, do not silently inherit the root model. Strip this header section — the subagent should only see what's below the line. **Task-type selection:** In the "Your Approach" section, keep only the matching subsection (Build / Brainstorm / Plan) and delete the others. For review/analysis tasks, use the Build approach (code inspection + testing). diff --git a/pipeline/implementer-prompt.md b/pipeline/implementer-prompt.md index da207d9..372a2d6 100644 --- a/pipeline/implementer-prompt.md +++ b/pipeline/implementer-prompt.md @@ -2,7 +2,7 @@ **Orchestrator instructions (do not include in the subagent prompt):** -Read this section, fill in the `{placeholders}` in the subagent prompt below, then pass everything from the `---` separator onward as the `prompt` parameter to the Agent tool with `subagent_type: "general-purpose"`. Strip this header section — the subagent should only see what's below the line. +Read this section and fill in the `{placeholders}`. Before dispatch, run `opc-harness model-route --node {NODE_ID} --node-type build --role implementer --dir {PROJECT_ROOT}`. Pass everything from the `---` separator onward as the Agent `prompt`, use `subagent_type: "general-purpose"`, and pass the returned route's `model` as the Agent model parameter. If routing fails or the host cannot honor the model, do not silently inherit the root model. Strip this header section — the subagent should only see what's below the line. --- diff --git a/pipeline/loop-protocol.md b/pipeline/loop-protocol.md index 64e036a..76aba26 100644 --- a/pipeline/loop-protocol.md +++ b/pipeline/loop-protocol.md @@ -402,6 +402,7 @@ Tick ordering (CRITICAL — the gate must fire BEFORE any work): 2. Find the claimed unit's verify: and eval: lines in plan.md — these tell you HOW to verify it. 3. Execute the unit. Key rules to re-verify each tick: - Review units MUST dispatch ≥2 independent subagents via Agent tool (never self-review) + - Before every Agent call, resolve and pass an explicit model with opc-harness model-route; never silently inherit the root model - Implement/fix units MUST produce a git commit - UI units MUST include a screenshot artifact - Use the unit's verify: line to run the correct verification command @@ -418,6 +419,7 @@ Tick ordering (CRITICAL — the gate must fire BEFORE any work): Review units MUST use independent subagents (Agent tool). The orchestrator: 1. Dispatches 2-5 reviewer agents in parallel via Agent tool + - Resolve each role with `opc-harness model-route --node review --node-type review --role {role} --dir {PROJECT_ROOT}` and pass the returned explicit model 2. Each agent gets: file list, acceptance criteria, project context 3. Each agent produces eval-{role}.md with 🔴/🟡/🔵 findings 4. Orchestrator collects evals and writes handshake diff --git a/pipeline/model-routing.md b/pipeline/model-routing.md new file mode 100644 index 0000000..fb5a9ff --- /dev/null +++ b/pipeline/model-routing.md @@ -0,0 +1,95 @@ +# Model Routing Protocol + +Model routing is independent from flow selection and role selection. Keep OPC's nodes, rounds, and reviewers unchanged; choose the least expensive model tier that can reliably perform each role. + +Claude Code uses the parent model when a subagent model is omitted. OPC therefore resolves a model before **every** Agent call and passes it explicitly. See the [Claude Code subagent model precedence](https://code.claude.com/docs/en/sub-agents#choose-a-model). + +## Resolve Before Dispatch + +```bash +opc-harness model-route \ + --node "$NODE_ID" \ + --node-type "$NODE_TYPE" \ + --role "$ROLE" \ + --dir "$PROJECT_ROOT" +``` + +The command returns JSON: + +```json +{ + "ok": true, + "dispatch": true, + "tier": "economy", + "model": "haiku", + "source": "role:tester", + "premium": false +} +``` + +- `dispatch: false` means the node is orchestrator/tool-only; do not spawn an Agent. +- Pass `model` as the Claude Code Agent tool's per-invocation model parameter. +- On another host, treat `model` as a host-native identifier and use that host's explicit model selector. +- If the host cannot honor an explicit model, stop instead of silently inheriting the root model. +- `PREMIUM_APPROVAL_REQUIRED` requires explicit user approval, then re-run with `--allow-premium`. +- `CLAUDE_CODE_SUBAGENT_MODEL` has runtime precedence and is surfaced as `envOverride: true`. + +## Defaults + +| Work | Tier | Claude Code default | +|---|---|---| +| Test design and user/UX observer roles | economy | `haiku` | +| Discussion, briefs, implementation, semantic review, hotfix | standard | `sonnet` | +| Explicit premium escalation only | premium | `inherit` | +| Execute and gate nodes | none | no Agent | + +These defaults prevent a premium root session from automatically turning every `general-purpose` child into the same premium model. + +## Layered Configuration + +Set `agentRouting` in `~/.opc/config.json` or repository `.opc/config.json`. Repository config overrides user config through OPC's existing deep-merge rules. + +```json +{ + "agentRouting": { + "defaultTier": "standard", + "unknownModelPolicy": "deny", + "allowPremiumByDefault": false, + "models": { + "economy": "haiku", + "standard": "sonnet", + "premium": "inherit" + }, + "nodeTypes": { + "discussion": "standard", + "brief": "standard", + "build": "standard", + "review": "standard", + "execute": "none", + "gate": "none" + }, + "nodes": { + "test-design": "economy", + "ux-simulation": "economy" + }, + "roles": { + "tester": "economy", + "new-user": "economy", + "security": "standard" + }, + "premiumModels": ["inherit", "opus"] + } +} +``` + +Values in `models` are host-native identifiers. A Codex or other host adapter can replace them with its own available high-value and premium model IDs without changing OPC's flow definitions. + +Resolution precedence is: + +1. Host-wide runtime override (`CLAUDE_CODE_SUBAGENT_MODEL` on Claude Code) +2. Role tier +3. Node tier +4. Node-type tier +5. `defaultTier` + +Do not choose a tier from role prestige. `security`, `architect`, or `devil-advocate` are scopes, not automatic premium-model approvals. Escalate only for concrete risk or repeated failure of a lower tier. diff --git a/pipeline/role-evaluator-prompt.md b/pipeline/role-evaluator-prompt.md index 81fc6db..356ad01 100644 --- a/pipeline/role-evaluator-prompt.md +++ b/pipeline/role-evaluator-prompt.md @@ -2,7 +2,7 @@ **Orchestrator instructions (do not include in the subagent prompt):** -Read this section, fill in the `{placeholders}` in the subagent prompt below, then pass everything from the `---` separator onward as the `prompt` parameter to the Agent tool with `subagent_type: "general-purpose"`. Strip this header section — the subagent should only see what's below the line. +Read this section and fill in the `{placeholders}`. Before each role dispatch, run `opc-harness model-route --node {NODE_ID} --node-type {NODE_TYPE} --role {role_name} --dir {PROJECT_ROOT}`. Pass everything from the `---` separator onward as the Agent `prompt`, use `subagent_type: "general-purpose"`, and pass the returned route's `model` as the Agent model parameter. If routing fails or the host cannot honor the model, do not silently inherit the root model. Strip this header section — the subagent should only see what's below the line. For each role evaluator, paste the full content of the role's `.md` file into the Identity/Expertise and Anti-Patterns placeholders. diff --git a/pipeline/ux-simulation-protocol.md b/pipeline/ux-simulation-protocol.md index 2a270b8..7f59bcc 100644 --- a/pipeline/ux-simulation-protocol.md +++ b/pipeline/ux-simulation-protocol.md @@ -130,6 +130,8 @@ If no previous run exists, this is a first run — skip delta, use absolute gate Dispatch **3 parallel subagents** using the Agent tool, each receiving: +Before dispatching each observer, run `opc-harness model-route --node ux-simulation --node-type execute --role {role} --dir {PROJECT_ROOT}` and pass the returned explicit `model` to the Agent tool. Keep all three observers; routing changes models, not observer topology. + - **Base prompt:** `role-evaluator-prompt.md` with the role `.md` content (including Observation Mode section) - **Observation appendix:** full content of `ux-observer-protocol.md` - **Tier:** `flow-state.tier`