review: 0-or-2+ lens rule, parallel-or-bust, downshifted subagent models (#710)
* review: 0-or-2+ lens rule, parallel-or-bust, downshifted subagent models
PR review wall-time was dominated by two failure modes: orchestrator
serial-dispatching subagents (despite prompt asking for parallel) and
running every lens on the same Opus tier as the orchestrator. Sample of
recent runs showed 25-60min reviews on small PRs, with 8-10min idle
gaps between subagent dispatches.
Three changes:
1. `action/modes.ts` — replace the soft "1 trivial / 2-3 typical /
4-5 high-stakes" lens calibration with a binary 0-or-2+ rule. Default
is 0 lenses (orchestrator handles review solo with optional cheap
tracerfrog dispatches). 2+ parallel lenses only fire for substantive
PRs (>5 files AND >200 lines) or high-stakes-subsystem touches. Never
exactly one. Both Review and IncrementalReview prompts get loud
ALL-CAPS framing on parallel dispatch — emit ALL Task tool_use blocks
in a single assistant turn before reading any result. Drop the
"do NOT lens-review the diff yourself" advice; orchestrator pulls
context aggressively, in parallel with the lens fan-out.
2. New `tracerfrog` subagent for mechanical code tracing ("where is X
used / who calls Y / what depends on Z"). Pure read+grep+report with
no judgment — orchestrator can dispatch many tracers cheaply in
parallel. Defined in `action/agents/reviewer.ts`. Wired into both
claude.ts (`--agents` JSON) and opencode.ts (`agent` config block).
3. Per-subagent model downshifts via `deriveSubagentModels`:
- Anthropic: reviewfrog → Sonnet, tracerfrog → Haiku
- OpenAI: both → gpt-5.4-mini
- other providers (xai, deepseek, gemini, etc.): inherit (no
standard tier triplet to downshift to)
Claude Code path always runs Anthropic so the downshift is hardcoded
inline in claude.ts. OpenCode uses the helper since orchestrator
provider varies.
Both runtimes' subagent-definition formats verified directly against
their source: `--agents` JSON `model` field (claude-code's
`AgentJsonSchema` accepts model+effort+maxTurns+more) and OpenCode's
`agent.{name}.model` config field (parsed via Provider.parseModel,
applied per-task in tool/task.ts line 92). Parallel dispatch is
infra-supported in both — only the orchestrator model's tool_use
emission pattern was the bottleneck.
Tests: subagentModels.test.ts (14 tests covering provider matrix),
subagentRegistration.test.ts (6 source-asserts catching shape
regressions in buildAgentsJson / buildReviewerAgentConfig).
* subagentModels: add openrouter routes (proxy/router mode)
Initial helper missed the openrouter prefix used by Pullfrog's router
proxy. preview-710 e2e showed the OpenCode + openrouter path receiving
no downshift — orchestrator and lenses both ran on opus-4.7 because
'openrouter/anthropic/claude-opus-4.7' didn't match any of the
anthropic/openai prefixes the helper checked.
Add explicit branches for 'openrouter/anthropic/...' (uses dot notation:
claude-sonnet-4.6 / claude-haiku-4.5) and 'openrouter/openai/...'
(gpt-5.4-mini for both reviewer and tracer). Same opus->sonnet,
sonnet->keep-but-haiku-tracer, haiku->no-op semantics as the direct
anthropic path.
* opencode: log resolved subagent models at startup
So we can verify per-subagent model overrides actually take effect at
runtime. Prints once per run alongside the existing model/effort log
lines.
* drop tracerfrog: keep reviewfrog only, LSP-powered tracer planned later
Removes the cheap-haiku-tracer subagent (TRACER_AGENT_NAME +
TRACER_SYSTEM_PROMPT, registrations in claude.ts/opencode.ts, dispatch
guidance in modes.ts). The mechanical-tracing use case will be served
better by an LSP-powered tool than by a separately-prompted subagent.
deriveSubagentModels collapses to a single { reviewer } shape; the
reviewfrog-on-Sonnet downshift stays. Same source-assert + provider-
matrix tests, minus the tracer-specific cases.
modes.ts wording: drop the 'subagent type cheat sheet' bullet, drop
the parenthetical 'often better served by tracerfrog than reviewfrog'
on the impact lens, drop tracerfrog from the same-turn-context-pulling
hint. The 0-or-2+ rule and ALL-CAPS parallel emphasis are unchanged.
* subagentModels: broader downshift coverage (gpt-pro, gemini-pro, grok); drop gpt-mini target
Scanned every resolved orchestrator slug in action/models.ts against
models.dev pricing data. Identified five clear cases where the
orchestrator is meaningfully expensive AND has a cheaper sibling that
remains capable enough for review-style judgment work.
Changes:
- Anthropic: opus → sonnet (kept; -40%)
- OpenAI: gpt → gpt-5.4 (was: gpt-mini; -54% instead of -85% but
preserves review-quality judgment — gpt-mini was too dumb)
- OpenAI: gpt-pro → gpt (NEW; -93%, biggest single unlock —
gpt-5.5-pro is $30/Mtok in vs gpt-5.5 at $5)
- Google: gemini-pro → gemini-flash (NEW; -75%)
- xAI: grok-4.3 → grok-4-1-fast (NEW; -80%)
Every branch handles the three routes in use: direct provider slug,
opencode-vendored, and openrouter-proxied. Variants below the downshift
target (mini/nano/flash/fast/sonnet/haiku) inherit (no further drop).
Skipped:
- DeepSeek: v4-flash ($0.14/Mtok) is too far below review judgment
threshold; v4-pro orchestrator already cheap ($0.55 blended).
- Moonshot: kimi-k2-thinking would only save 32% and slug stability on
OpenRouter is uncertain; revisit if cost matters.
- o3: already mid-tier in OpenAI's reasoning family; no clean target.
* models: hoist subagent downshift into the registry, add hidden flag
The downshift relationship now lives next to each alias's resolve /
openRouterResolve as a sibling field. Two new ModelDef fields:
- subagentModel?: string — alias key (within same provider) of the
cheaper sibling reviewfrog should use as a lens-fanout subagent.
e.g. claude-opus → 'claude-sonnet'.
- hidden?: boolean — exclude from selectable lists (UI dropdown,
CLI init picker). Does NOT affect resolution; for that use
fallback. Used so internal-only subagent targets like openai/gpt-5.4
exist in the registry but never appear as a user-facing pick.
Wiring:
- anthropic.claude-opus → claude-sonnet (-40%)
- openai.gpt-pro → gpt (-93%, biggest unlock)
- openai.gpt → gpt-5.4 (-54%); gpt-5.4 added with hidden:true
- google.gemini-pro → gemini-flash (-75%)
- mirrored across opencode + openrouter providers (each provider
declares its own three-route data so the downshift declaration
is colocated with the rest of the alias definition).
deriveSubagentModels collapses from ~85 lines of prefix-matching to
a ~15-line registry reverse-lookup: find the alias whose resolve OR
openRouterResolve matches the orchestrator's spec, follow its
subagentModel pointer, return the matching field of the target alias.
Filter sites updated:
- components/ModelSelector.tsx: !a.fallback && !a.hidden
- action/commands/init.ts: same
Tests rewritten to exercise the registry through the public surface;
the matrix collapses to one assertion per (provider × route) pair.
* TEMP: log per-step cost+tokens for subagent model verification (PR #710)
* TEMP: also log SUBAGENT step_finish from bus envelope handler
* remove temporary per-step diagnostic logs (verification done)
Verified subagent model downshift takes effect end-to-end on the OpenCode
+ openrouter path. PR #8 in pullfrog/preview-710-review-perf dispatched
3 lenses (billing-subsystem / security / correctness) on the orchestrator's
opus-4.7 session, and per-subagent step_finish events showed actual cost
exactly matching Sonnet pricing rates (60% of what Opus would have cost):
session n actual if-Opus if-Sonnet match
T3VrUuF... 5 $0.2425 $0.4042 $0.2425 Sonnet ✓
93ZZR7E... 4 $0.2253 $0.3754 $0.2253 Sonnet ✓
Fb1Kr7b... 4 $0.2495 $0.4158 $0.2495 Sonnet ✓
The startup '» subagent models: reviewfrog=...' line stays — useful
permanent diagnostic showing the resolved subagent model per-run.
* TEMP: log per-event model from claude.ts assistant handler
* remove temporary per-event model log (claude.ts verification done)
Verified subagent model downshift takes effect end-to-end on the Claude
Code path. PR #9 in pullfrog/preview-710-review-perf dispatched 2 lenses
on an opus-4-7 orchestrator. Per-assistant-event model field from the
SDK's stream-json output, partitioned by parent_tool_use_id:
ORCH (parent_tool_use_id=null): 17 events all model=claude-opus-4-7
SUBAGENT lens:billing-subsystem: 17 events all model=claude-sonnet-4-6
SUBAGENT lens:security: 21 events all model=claude-sonnet-4-6
Zero leakage to opus from either subagent session. The per-subagent
'model' field in --agents JSON is honored by claude-code at the SDK
level, identical to the OpenCode path verified earlier.
* opencode: bump per-call output cap 5K → 16K to unblock large reviews
The 5K cap (added in #616 to lower OpenRouter upfront budget reservation
for low-wallet runs) was capping the entire response of a single LLM call,
not just the budget reservation. A single tool_use response — like a
`create_pull_request_review` with many inline comments — would truncate
mid-stream past 5K output tokens, leave the JSON unparseable, and the tool
would never actually invoke. We hit this on PR #710's verify-downshift PR:
review aggregated from 3 lenses had 11 inline comments + a long body,
truncated at out=5000 on every retry attempt, action exited with 'Review
mode finished without calling create_pull_request_review after 3 retry
attempts'.
Investigated whether OpenCode (or OpenAI/Anthropic/OpenRouter directly)
exposes a separate budget-reservation parameter that could stay small
while letting the response exceed it. They don't — `max_tokens` /
`max_completion_tokens` is the single value all four use for both the
upfront reservation and the hard output ceiling. No way to decouple them
at the API surface.
Bumped to 16K as a middle ground: 8× the prior cap (handles every review
shape we've observed plus headroom), still half of OpenCode's 32K default
so the wallet-burn benefit for low-balance accounts is preserved, just
smaller. For Opus 4.7 a typical ~50K-input call now reserves roughly
$0.65 instead of the prior $0.38.
Updated the constant comment to spell out the trade-off clearly so this
doesn't happen again.
* opencode: drop OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX override entirely
Verified the original rationale for the override is obsolete. From #616
the cap shrunk OpenRouter's per-call upfront budget reservation so a
single call's reservation wouldn't exceed the per-run key cap
(`ROUTER_PER_RUN_LIMIT_USD = 25`) and lock low-balance accounts out of
starting a run.
That per-run gate is gone. `app/api/proxy-token/route.ts` ~line 422
explicitly says: 'No upper cap (the old ROUTER_PER_RUN_LIMIT_USD = 25 is
gone). The natural ceiling is whatever the user has + their buffer.'
Router now mints keys with `keyLimitCents = balance + buffer` ($50 for
autoreload+card, $5 for card-only, $0 for no-card). A single call's
upfront reservation fits comfortably within that — no separate per-call
gate to fail past.
The cap had a real downside as a hard per-call output truncation. A
single `create_pull_request_review` tool_use with many inline comments
would truncate mid-stream past 5K output tokens, the JSON would be
unparseable, and the tool never invoked. Hit on PR #710's
verify-downshift PR.
Removing the override entirely; OpenCode falls back to its 32K default.
Left an explanatory note above the env-var assignment site so the next
person doesn't unknowingly re-add it.
This commit is contained in:
committed by
pullfrog[bot]
parent
5518890b18
commit
5caeb75344
+13
-3
@@ -72,17 +72,27 @@ function writeMcpConfig(ctx: AgentRunContext): string {
|
||||
|
||||
/**
|
||||
* Build the `--agents` JSON definition for the `reviewfrog` subagent.
|
||||
*
|
||||
* The Claude Code path always runs against an Anthropic model (see
|
||||
* resolveAgent), so we hardcode the cheaper-sibling downshift: lenses run
|
||||
* on Sonnet, the orchestrator stays on whatever model `--model` was passed.
|
||||
*
|
||||
* Per-call model override is also possible (Task tool's `model` arg accepts
|
||||
* 'sonnet' | 'opus' | 'haiku') and takes precedence over what's set here —
|
||||
* we don't pass it; the per-subagent `model` field is the right default.
|
||||
*
|
||||
* The non-mutative + non-recursive contract is enforced by the prose system
|
||||
* prompt baked into the agent — see action/agents/reviewer.ts for why we no
|
||||
* longer wire per-agent `disallowedTools` here.
|
||||
* prompt baked into the agent — see action/agents/reviewer.ts for why we
|
||||
* no longer wire per-agent `disallowedTools` here.
|
||||
*/
|
||||
function buildAgentsJson(): string {
|
||||
const agents = {
|
||||
[REVIEWER_AGENT_NAME]: {
|
||||
description:
|
||||
"Read-only review subagent for self-review and lens-based code review. " +
|
||||
"Read-only review subagent for lens-based code review (correctness, security, billing-subsystem, etc.). " +
|
||||
"Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
|
||||
prompt: REVIEWER_SYSTEM_PROMPT,
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
};
|
||||
return JSON.stringify(agents);
|
||||
|
||||
+39
-23
@@ -48,6 +48,7 @@ import {
|
||||
logTokenTable,
|
||||
MAX_STDERR_LINES,
|
||||
} from "./shared.ts";
|
||||
import { deriveSubagentModels } from "./subagentModels.ts";
|
||||
|
||||
async function installOpencodeCli(): Promise<string> {
|
||||
return await installFromNpmTarball({
|
||||
@@ -71,21 +72,25 @@ type OpenCodeConfig = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-inference `max_tokens` reservation the agent sends to the upstream
|
||||
* model. OpenCode's default is 32_000 (sized for long-running TUI sessions
|
||||
* where a human user might want big outputs). Pullfrog runs are headless and
|
||||
* short — typical outputs are 1-3K tokens — so we cap at 5_000. This
|
||||
* drastically reduces the upfront budget reservation OpenRouter requires per
|
||||
* call (~$0.38 vs ~$2.40 for Opus), which is what lets low-wallet runs
|
||||
* actually start.
|
||||
*
|
||||
* Plumbed via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` env var rather than the
|
||||
* config JSON. OpenCode's `OUTPUT_TOKEN_MAX` (session/llm.ts) is sourced
|
||||
* exclusively from this env var; top-level `limit.output` in the config
|
||||
* has no read site and is silently dropped on merge.
|
||||
*/
|
||||
const PULLFROG_OPENCODE_OUTPUT_LIMIT = 5000;
|
||||
// NOTE: OpenCode's per-call `max_tokens` defaults to 32_000. We previously
|
||||
// overrode this via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = 5000` in #616
|
||||
// to lower OpenRouter's per-call upfront budget reservation — back when the
|
||||
// `ROUTER_PER_RUN_LIMIT_USD = 25` per-run key cap meant that reservation was
|
||||
// a hard gate that could lock low-balance accounts out of starting a run.
|
||||
//
|
||||
// That gate is gone (see `app/api/proxy-token/route.ts` ~line 422 — "Per-run
|
||||
// key budget … is decoupled from wallet balance"); the router now mints
|
||||
// keys with `keyLimitCents = balance + buffer` ($50 / $5 / $0). The override
|
||||
// no longer materially helps, and as a hard per-call output truncation it
|
||||
// actively hurt: a single `create_pull_request_review` tool_use with many
|
||||
// inline comments would truncate mid-stream past 5K output tokens, the JSON
|
||||
// was unparseable, and the tool never invoked. We hit this on PR #710's
|
||||
// verify-downshift PR. Removed in #710 — using OpenCode's 32K default.
|
||||
//
|
||||
// If you need to re-cap output for some reason, set
|
||||
// `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` in the action env. OpenCode's
|
||||
// top-level `limit.output` config field has no read site (silently dropped
|
||||
// on merge in session/llm.ts), so the env var is the only working knob.
|
||||
|
||||
/**
|
||||
* upstream opencode hardcodes `thinkingLevel: "high"` as the default for every
|
||||
@@ -120,7 +125,12 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
||||
mcp: {
|
||||
[pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
|
||||
},
|
||||
agent: buildReviewerAgentConfig(),
|
||||
agent: (() => {
|
||||
const cfg = buildReviewerAgentConfig(model);
|
||||
const reviewerModel = (cfg[REVIEWER_AGENT_NAME] as { model?: string })?.model ?? "(inherit)";
|
||||
log.info(`» subagent models: reviewfrog=${reviewerModel}`);
|
||||
return cfg;
|
||||
})(),
|
||||
// opt into opencode's experimental `batch` tool (added in
|
||||
// anomalyco/opencode PR #2983, opt-in via `experimental.batch_tool`). it
|
||||
// exposes a single `batch` tool that runs 1-25 independent tool calls
|
||||
@@ -159,19 +169,26 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only subagent for self-review and /anneal lens dispatch. The
|
||||
* non-mutative + non-recursive contract is enforced by the prose system
|
||||
* prompt — see action/agents/reviewer.ts for why we no longer wire per-agent
|
||||
* tool/permission denies here.
|
||||
* Read-only `reviewfrog` subagent for lens-based review.
|
||||
*
|
||||
* Non-mutative + non-recursive — enforced by the prose system prompt in
|
||||
* reviewer.ts.
|
||||
*
|
||||
* Per-subagent `model:` override downshifts to a cheaper sibling when the
|
||||
* orchestrator runs on Anthropic or OpenAI (see deriveSubagentModels).
|
||||
* Other providers (xai, deepseek, gemini, etc.) inherit the orchestrator's
|
||||
* model since their tier structure is less standard.
|
||||
*/
|
||||
function buildReviewerAgentConfig(): Record<string, unknown> {
|
||||
function buildReviewerAgentConfig(orchestratorModel: string | undefined): Record<string, unknown> {
|
||||
const overrides = deriveSubagentModels(orchestratorModel);
|
||||
return {
|
||||
[REVIEWER_AGENT_NAME]: {
|
||||
description:
|
||||
"Read-only review subagent for self-review and lens-based code review. " +
|
||||
"Read-only review subagent for lens-based code review (correctness, security, billing-subsystem, etc.). " +
|
||||
"Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
|
||||
mode: "subagent",
|
||||
prompt: REVIEWER_SYSTEM_PROMPT,
|
||||
...(overrides.reviewer !== undefined ? { model: overrides.reviewer } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1199,7 +1216,6 @@ export const opencode = agent({
|
||||
...homeEnv,
|
||||
OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
|
||||
OPENCODE_PERMISSION: permissionOverride,
|
||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: PULLFROG_OPENCODE_OUTPUT_LIMIT.toString(),
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveSubagentModels } from "./subagentModels.ts";
|
||||
|
||||
describe("deriveSubagentModels", () => {
|
||||
it("returns no override when orchestrator is undefined", () => {
|
||||
expect(deriveSubagentModels(undefined)).toEqual({ reviewer: undefined });
|
||||
});
|
||||
|
||||
it("returns no override when orchestrator slug isn't registered", () => {
|
||||
expect(deriveSubagentModels("nonexistent/model")).toEqual({ reviewer: undefined });
|
||||
});
|
||||
|
||||
describe("anthropic family — opus → sonnet", () => {
|
||||
it("direct anthropic opus", () => {
|
||||
expect(deriveSubagentModels("anthropic/claude-opus-4-7")).toEqual({
|
||||
reviewer: "anthropic/claude-sonnet-4-6",
|
||||
});
|
||||
});
|
||||
it("opencode-vendored opus stays on opencode prefix", () => {
|
||||
expect(deriveSubagentModels("opencode/claude-opus-4-7")).toEqual({
|
||||
reviewer: "opencode/claude-sonnet-4-6",
|
||||
});
|
||||
});
|
||||
it("openrouter-anthropic-opus-via-anthropic-direct hits anthropic alias's openRouterResolve", () => {
|
||||
// both the anthropic alias and the opencode alias have the same
|
||||
// openRouterResolve. first-match-wins by alias declaration order
|
||||
// (anthropic declared first in providers).
|
||||
expect(deriveSubagentModels("openrouter/anthropic/claude-opus-4.7")).toEqual({
|
||||
reviewer: "openrouter/anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
});
|
||||
it("sonnet has no further downshift", () => {
|
||||
expect(deriveSubagentModels("anthropic/claude-sonnet-4-6")).toEqual({ reviewer: undefined });
|
||||
expect(deriveSubagentModels("opencode/claude-sonnet-4-6")).toEqual({ reviewer: undefined });
|
||||
});
|
||||
it("haiku has no downshift", () => {
|
||||
expect(deriveSubagentModels("anthropic/claude-haiku-4-5")).toEqual({ reviewer: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai family", () => {
|
||||
it("gpt-pro → gpt (direct)", () => {
|
||||
expect(deriveSubagentModels("openai/gpt-5.5-pro")).toEqual({ reviewer: "openai/gpt-5.5" });
|
||||
});
|
||||
it("gpt → gpt-5.4 (direct)", () => {
|
||||
expect(deriveSubagentModels("openai/gpt-5.5")).toEqual({ reviewer: "openai/gpt-5.4" });
|
||||
});
|
||||
it("gpt → gpt-5.4 (opencode-vendored)", () => {
|
||||
expect(deriveSubagentModels("opencode/gpt-5.5")).toEqual({ reviewer: "opencode/gpt-5.4" });
|
||||
});
|
||||
it("gpt-pro → gpt (openrouter)", () => {
|
||||
expect(deriveSubagentModels("openrouter/openai/gpt-5.5-pro")).toEqual({
|
||||
reviewer: "openrouter/openai/gpt-5.5",
|
||||
});
|
||||
});
|
||||
it("gpt → gpt-5.4 (openrouter)", () => {
|
||||
expect(deriveSubagentModels("openrouter/openai/gpt-5.5")).toEqual({
|
||||
reviewer: "openrouter/openai/gpt-5.4",
|
||||
});
|
||||
});
|
||||
it("gpt-5.4 itself (the hidden subagent target) has no further downshift", () => {
|
||||
expect(deriveSubagentModels("openai/gpt-5.4")).toEqual({ reviewer: undefined });
|
||||
});
|
||||
it("gpt-mini has no downshift", () => {
|
||||
expect(deriveSubagentModels("openai/gpt-5.4-mini")).toEqual({ reviewer: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe("google (gemini) — pro → flash", () => {
|
||||
it("direct google", () => {
|
||||
expect(deriveSubagentModels("google/gemini-3.1-pro-preview")).toEqual({
|
||||
reviewer: "google/gemini-3-flash-preview",
|
||||
});
|
||||
});
|
||||
it("opencode-vendored gemini-pro", () => {
|
||||
expect(deriveSubagentModels("opencode/gemini-3.1-pro")).toEqual({
|
||||
reviewer: "opencode/gemini-3-flash",
|
||||
});
|
||||
});
|
||||
it("openrouter-google-gemini-pro", () => {
|
||||
expect(deriveSubagentModels("openrouter/google/gemini-3.1-pro-preview")).toEqual({
|
||||
reviewer: "openrouter/google/gemini-3-flash-preview",
|
||||
});
|
||||
});
|
||||
it("flash has no downshift", () => {
|
||||
expect(deriveSubagentModels("google/gemini-3-flash-preview")).toEqual({
|
||||
reviewer: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("providers / models without a subagentModel — inherit", () => {
|
||||
it("xai grok (already cheap flagship)", () => {
|
||||
expect(deriveSubagentModels("xai/grok-4.3")).toEqual({ reviewer: undefined });
|
||||
});
|
||||
it("deepseek", () => {
|
||||
expect(deriveSubagentModels("deepseek/deepseek-v4-pro")).toEqual({ reviewer: undefined });
|
||||
});
|
||||
it("moonshot kimi", () => {
|
||||
expect(deriveSubagentModels("moonshotai/kimi-k2.6")).toEqual({ reviewer: undefined });
|
||||
});
|
||||
it("opencode big-pickle", () => {
|
||||
expect(deriveSubagentModels("opencode/big-pickle")).toEqual({ reviewer: undefined });
|
||||
});
|
||||
it("legacy fallback aliases (gpt-codex, deepseek-reasoner)", () => {
|
||||
expect(deriveSubagentModels("openai/gpt-5.3-codex")).toEqual({ reviewer: undefined });
|
||||
expect(deriveSubagentModels("deepseek/deepseek-reasoner")).toEqual({ reviewer: undefined });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { modelAliases } from "../models.ts";
|
||||
|
||||
/**
|
||||
* Derive a cheaper subagent model override from the orchestrator's resolved
|
||||
* model spec.
|
||||
*
|
||||
* This is a pure registry lookup: every alias in `action/models.ts` declares
|
||||
* its own `subagentModel` (alias key in the same provider). At runtime we
|
||||
* reverse-lookup the orchestrator's resolved slug to find the alias that
|
||||
* produced it, follow the `subagentModel` pointer, and return the target
|
||||
* alias's resolve / openRouterResolve depending on which route the
|
||||
* orchestrator was using.
|
||||
*
|
||||
* Returns `{ reviewer: undefined }` when the orchestrator's alias has no
|
||||
* `subagentModel` (e.g. it's already at a sufficiently cheap tier, or its
|
||||
* provider doesn't have a clean cheaper-but-capable sibling). See models.ts
|
||||
* for the wiring + per-provider rationale.
|
||||
*/
|
||||
export function deriveSubagentModels(orchestratorSpec: string | undefined): {
|
||||
reviewer: string | undefined;
|
||||
} {
|
||||
if (!orchestratorSpec) return { reviewer: undefined };
|
||||
|
||||
// Reverse-lookup. The same resolve string appears in only one alias
|
||||
// (within its provider), so first match wins. We track which field
|
||||
// matched (resolve vs openRouterResolve) so we can pick the same field
|
||||
// off the subagent target — keeping the orchestrator's route consistent.
|
||||
for (const source of modelAliases) {
|
||||
const matchedDirect = source.resolve === orchestratorSpec;
|
||||
const matchedOR = source.openRouterResolve === orchestratorSpec;
|
||||
if (!matchedDirect && !matchedOR) continue;
|
||||
if (!source.subagentModel) return { reviewer: undefined };
|
||||
const target = modelAliases.find((a) => a.slug === source.subagentModel);
|
||||
if (!target) return { reviewer: undefined };
|
||||
const reviewer = matchedOR ? target.openRouterResolve : target.resolve;
|
||||
return { reviewer };
|
||||
}
|
||||
|
||||
return { reviewer: undefined };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const claudeSource = readFileSync(join(__dirname, "claude.ts"), "utf-8");
|
||||
const opencodeSource = readFileSync(join(__dirname, "opencode.ts"), "utf-8");
|
||||
|
||||
/**
|
||||
* The Claude Code `--agents` JSON and OpenCode `agent` config block are the
|
||||
* only places where per-subagent model overrides take effect. They're built
|
||||
* by string-only helpers we don't export, so this test reads the source and
|
||||
* asserts the literal model strings + agent names are wired in. A regression
|
||||
* here means the next review run silently runs lenses on Opus instead of
|
||||
* Sonnet.
|
||||
*/
|
||||
describe("subagent registration source asserts", () => {
|
||||
describe("claude.ts buildAgentsJson", () => {
|
||||
it("registers reviewfrog with sonnet model", () => {
|
||||
expect(claudeSource).toMatch(
|
||||
/\[REVIEWER_AGENT_NAME\]:\s*\{[^}]*model:\s*"claude-sonnet-4-6"/s
|
||||
);
|
||||
});
|
||||
it("imports the reviewer name constant", () => {
|
||||
expect(claudeSource).toMatch(/REVIEWER_AGENT_NAME/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("opencode.ts buildReviewerAgentConfig", () => {
|
||||
it("registers reviewfrog with mode: subagent", () => {
|
||||
expect(opencodeSource).toMatch(/\[REVIEWER_AGENT_NAME\]:[^}]*mode:\s*"subagent"/s);
|
||||
});
|
||||
it("uses deriveSubagentModels for the reviewer model override", () => {
|
||||
expect(opencodeSource).toMatch(/deriveSubagentModels\(/);
|
||||
expect(opencodeSource).toMatch(/overrides\.reviewer/);
|
||||
});
|
||||
it("passes orchestrator model to buildReviewerAgentConfig", () => {
|
||||
expect(opencodeSource).toMatch(/buildReviewerAgentConfig\(model\)/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user