fix(#3531): merge routing_tier_defaults over manifest tier defaults (#3539)

* test(#3531): failing-first suite for routing_tier_defaults manifest merge

* fix(#3531): merge routing_tier_defaults over manifest tier defaults

* docs(#3531): document routing_tier_defaults merge-over-built-ins semantics

* fix(#3531): correct test helper scope, update folded #443 expectations, guard merge keys

* test(#3531): pin tiers in effort-sync and surface-axis fixtures post-merge

* chore(#3531): backfill changeset pr number

* fix(#3531): correct rebase resolution — keep both 3531 and 3533 test blocks intact

* test(#3531): pin inherit/effort fixtures to the layer that reaches tiered agents

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-15 08:19:35 -04:00
committed by GitHub
parent bc9a22868f
commit b7cca0363f
9 changed files with 328 additions and 56 deletions

View File

@@ -23,6 +23,9 @@ import os from 'node:os';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module
import modelResolver = require('./model-resolver.cjs');
const { EFFORT_SET: GSD_EFFORT_SET } = modelResolver as { EFFORT_SET: Set<string> };
// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-catalog.cjs is an export= CommonJS module
import modelCatalog = require('./model-catalog.cjs');
const { mergeEffortTierDefaults } = modelCatalog as { mergeEffortTierDefaults: typeof import('./model-catalog.cjs').mergeEffortTierDefaults };
interface EffortConfig {
agent_overrides?: Record<string, unknown>;
@@ -161,6 +164,11 @@ function readGsdEffectiveEffortConfig(targetDir: string | null = null): EffortCo
// Per-project wins on conflict within each sub-field. Merge field-by-field so
// a project config that only sets agent_overrides still inherits global
// routing_tier_defaults and default.
// #3531 (10c): routing_tier_defaults is deep-merged per-tier like
// agent_overrides — a project block naming only `heavy` must not discard the
// home block's `light`/`standard` entries (the top-level spread would
// otherwise replace the whole block, the same defect class as the
// manifest-replacement this change fixes).
return {
...(homeEffort || {}),
...(projectEffort || {}),
@@ -169,6 +177,11 @@ function readGsdEffectiveEffortConfig(targetDir: string | null = null): EffortCo
...((homeEffort && homeEffort.agent_overrides) || {}),
...((projectEffort && projectEffort.agent_overrides) || {}),
},
// Deep-merge routing_tier_defaults (project wins per-tier)
routing_tier_defaults: {
...((homeEffort && homeEffort.routing_tier_defaults) || {}),
...((projectEffort && projectEffort.routing_tier_defaults) || {}),
},
};
}
@@ -179,8 +192,10 @@ function readGsdEffectiveEffortConfig(targetDir: string | null = null): EffortCo
*
* Precedence (mirrors resolveEffortInternal):
* 1. effortCfg.agent_overrides[agentName]
* 2. effortCfg.routing_tier_defaults[agentTier] (if effortCfg present)
* — OR manifest tier defaults when effortCfg is null
* 2. routing_tier_defaults merged over the manifest tier defaults
* (#3531/10c: a config block — present or partial — no longer disables
* the built-in tier ladder; invalid config values are dropped by the
* merge so the manifest value for the tier surfaces)
* 3. effortCfg.default
* 4. 'high' (hardcoded fallback)
*
@@ -207,17 +222,14 @@ function resolveInstallTimeEffort(effortCfg: EffortConfig | null, agentName: str
const { AGENT_DEFAULT_TIERS, EFFORT_MANIFEST_TIER_DEFAULTS, EFFORT_MANIFEST_DEFAULT } = _getGsdEffortCatalog();
const agentTier = AGENT_DEFAULT_TIERS[agentName];
if (agentTier) {
if (effortCfg && effortCfg.routing_tier_defaults &&
typeof effortCfg.routing_tier_defaults === 'object' &&
!Array.isArray(effortCfg.routing_tier_defaults)) {
const v = effortCfg.routing_tier_defaults[agentTier];
if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v;
} else if (!effortCfg) {
// No effort config — use manifest tier defaults
const v = EFFORT_MANIFEST_TIER_DEFAULTS[agentTier];
if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v;
}
// effortCfg exists but has no routing_tier_defaults — fall through
const isValidEffort = (v: unknown): v is string => typeof v === 'string' && GSD_EFFORT_SET.has(v);
const merged = mergeEffortTierDefaults(
EFFORT_MANIFEST_TIER_DEFAULTS,
effortCfg ? effortCfg.routing_tier_defaults : undefined,
isValidEffort,
);
const v = merged[agentTier];
if (isValidEffort(v)) return v;
}
// Step 3: effort.default

View File

@@ -343,5 +343,36 @@ export function renderEffortForRuntime(runtime: string, universalEffort: string)
};
}
/**
* #3531 (10c) — Merge a config `effort.routing_tier_defaults` block over the
* manifest tier defaults instead of replacing them. A partial config must not
* discard built-ins: per tier, a valid override value wins and an invalid one
* is ignored so the manifest value for that tier surfaces (ADR-443 D1's
* "invalid values fall through" holds within the merged layer).
*
* Pure: returns a new object and never mutates either input — the manifest
* constants (`CANONICAL_CONFIG_DEFAULTS`, the catalog cache) stay frozen. The
* validator is injected because `EFFORT_SET` lives in model-resolver, which
* imports this leaf (a reverse import would be a cycle); both effort
* resolvers pass their own `(v) => typeof v === 'string' && EFFORT_SET.has(v)`.
*/
export function mergeEffortTierDefaults(
manifest: Record<string, string> | null | undefined,
override: unknown,
isValid: (v: unknown) => boolean,
): Record<string, string> {
const merged: Record<string, string> = { ...(manifest || {}) };
if (override && typeof override === 'object' && !Array.isArray(override)) {
for (const [tier, value] of Object.entries(override as Record<string, unknown>)) {
// House pollution guard (mirrors _deepMergeConfig in config-loader): the
// string-only validator already makes these inert, but an explicit skip
// keeps this merge safe even if a caller's validator is ever relaxed.
if (tier === '__proto__' || tier === 'constructor' || tier === 'prototype') continue;
if (isValid(value)) merged[tier] = value as string;
}
}
return merged;
}
// ─── Fast mode propagation ───────────────────────────────────────────────────
export const RUNTIMES_WITH_FAST_MODE: Set<string> = new Set(['api']);

View File

@@ -31,7 +31,7 @@ import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS } from './configuration.cj
import modelProfiles = require('./model-profiles.cjs');
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS, VALID_TIERS, CLAUDE_AGENT_ALIASES } from './model-catalog.cjs';
import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS, VALID_TIERS, CLAUDE_AGENT_ALIASES, mergeEffortTierDefaults } from './model-catalog.cjs';
import fs from 'node:fs';
import path from 'node:path';
@@ -801,23 +801,26 @@ function resolveEffortInternal(cwd: string, agentType: string, opts?: EffortOpts
}
// Step 3: routing_tier_defaults by agent's default tier.
// #3531 (10c): the config block merges OVER the manifest tier defaults
// rather than replacing them — an effort block without
// routing_tier_defaults (or missing this agent's tier) falls back to the
// manifest built-in for that tier instead of skipping to effort.default.
// Invalid config values are dropped by the merge, so the manifest value for
// the tier surfaces (the same "invalid falls through" rule every layer has).
const agentTier = (AGENT_DEFAULT_TIERS)[agentType];
if (agentTier) {
if (effortCfg && effortCfg['routing_tier_defaults'] &&
typeof effortCfg['routing_tier_defaults'] === 'object' &&
!Array.isArray(effortCfg['routing_tier_defaults'])) {
const v = (effortCfg['routing_tier_defaults'] as Record<string, unknown>)[agentTier];
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
} else if (!effortCfg) {
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
const manifestDefaults = canonicalEffort && typeof canonicalEffort === 'object'
? (canonicalEffort as Record<string, unknown>)['routing_tier_defaults']
: undefined;
if (manifestDefaults && typeof manifestDefaults === 'object') {
const v = (manifestDefaults as Record<string, unknown>)[agentTier];
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
}
}
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
const manifestDefaults = canonicalEffort && typeof canonicalEffort === 'object'
? (canonicalEffort as Record<string, unknown>)['routing_tier_defaults'] as Record<string, string> | undefined
: undefined;
const isValidEffort = (v: unknown): v is string => typeof v === 'string' && EFFORT_SET.has(v);
const merged = mergeEffortTierDefaults(
manifestDefaults,
effortCfg ? effortCfg['routing_tier_defaults'] : undefined,
isValidEffort,
);
const v = merged[agentTier];
if (isValidEffort(v)) return v;
}
// Step 4: effort.default