feat(#2481): add a negotiated effortSurface axis and wire invocation-time effort
ADR-1239 gains a ninth negotiated axis, effortSurface (argv | none), declaring how
a host accepts reasoning effort. ADR-443 is amended in the same change because its
recorded deferral is what the axis resolves: its Unblock condition offered paths
(a) and (b) and stated the choice was 'a maintainer call this file records but does
not make'. Path (a) is selected and satisfied here.
Before this, effort reached a runtime only through install-time channels
(EFFORT_RENDERING's frontmatter/api), so reviewer CLIs spawned as subprocesses
silently inherited whatever effort sat in the user's own global CLI config. The
review lane now resolves one universal effort through the ADR-443 cascade and
renders it per host through the negotiated descriptor.
Every per-host value is documentation-sourced, never inferred:
- claude argv -- verified via 'claude --help' (--effort <level>)
- opencode argv -- verified via 'opencode run --help' (--variant)
- codex argv -- codex-rs/exec/src/cli.rs: model_reasoning_effort is NOT a CLI
flag (config.toml key only), so the global -c override is the
only argv route
- 15 hosts undocumented -- their docs state no reasoning setting; the sentinel
fails closed rather than inheriting a profile baseline
No config-file vocabulary member: the only host that ever had one (Gemini CLI's
thinkingConfig) was removed as a sunset runtime by 8f2ebbe9b (#1928, PR #1996),
and neither Antigravity CLI nor ZCode documents a reasoning setting.
Closes #2481
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,9 @@ const { resolveModelInternal, resolveModelForTier, resolveProviderEscalation, re
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import agentCommandRouterMod = require('./agent-command-router.cjs');
|
||||
const { AGENT_FAILURE_CLASSES } = agentCommandRouterMod;
|
||||
import { renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE } from './model-catalog.cjs';
|
||||
import { renderEffortForRuntime, renderEffortArgv, RUNTIMES_WITH_FAST_MODE } from './model-catalog.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import hostIntegrationMod = require('./host-integration.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir, planningPaths } = planningWorkspace;
|
||||
@@ -525,9 +527,9 @@ function cmdResolveGranularity(cwd: string, phaseType: string | undefined, raw:
|
||||
* fast_mode, fast_mode_supported, [unknown_agent] }
|
||||
*
|
||||
* Flags: --effort <level>, --fast-mode <true|false>, --attempt <n>,
|
||||
* --failure-class <class> (#2296)
|
||||
* --failure-class <class> (#2296), --host <runtime-id> (#2481)
|
||||
*/
|
||||
function cmdResolveExecution(cwd: string, agentType: string | undefined, raw: boolean, opts?: { effortOverride?: string; fastModeOverride?: boolean; attempt?: number; failureClass?: string }): void {
|
||||
function cmdResolveExecution(cwd: string, agentType: string | undefined, raw: boolean, opts?: { effortOverride?: string; fastModeOverride?: boolean; attempt?: number; failureClass?: string; host?: string }): void {
|
||||
if (!agentType) {
|
||||
error('agent-type required');
|
||||
}
|
||||
@@ -587,11 +589,55 @@ function cmdResolveExecution(cwd: string, agentType: string | undefined, raw: bo
|
||||
fast_mode: fastMode,
|
||||
fast_mode_supported: fastModeSupported,
|
||||
};
|
||||
// ADR-1239 amendment (#2481) / ADR-443 path (a): invocation-time effort for a
|
||||
// named host. The host's negotiated `effortSurface` decides WHETHER an argument
|
||||
// is emitted; the catalog knows the syntax. Absent --host the contract is
|
||||
// byte-identical to before, so every existing caller is unaffected.
|
||||
if (typeof opts.host === 'string' && opts.host.length > 0) {
|
||||
const surface = effortSurfaceForHost(cwd, opts.host);
|
||||
const argvRendered = renderEffortArgv(opts.host, effort, surface);
|
||||
result['host'] = opts.host;
|
||||
result['effort_surface'] = surface;
|
||||
result['effort_argv'] = argvRendered.argv;
|
||||
result['effort_argv_string'] = argvRendered.argv.join(' ');
|
||||
result['effort_argv_value'] = argvRendered.value;
|
||||
}
|
||||
|
||||
if (!agentModels) result['unknown_agent'] = true;
|
||||
if (escalation) result['escalation'] = escalation;
|
||||
output(result, raw, effort);
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-1239 amendment (#2481) — resolve a host's negotiated `effortSurface`.
|
||||
*
|
||||
* Reads the host's runtime descriptor from the generated capability registry and
|
||||
* runs it through the Host-Integration negotiation so the trust-boundary invariant
|
||||
* applies here exactly as everywhere else: an unknown host, a missing axis, or the
|
||||
* `undocumented` sentinel all degrade to the safe floor rather than being trusted.
|
||||
* Never throws — a lookup failure yields `'none'`, which renders no argument.
|
||||
*/
|
||||
function effortSurfaceForHost(cwd: string, host: string): string {
|
||||
void cwd;
|
||||
try {
|
||||
// Mirrors the lazy-require pattern from runtime-slash.cts §runtimeSlash —
|
||||
// capability-registry.cjs is generated and carries no type declarations.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { runtimes } = require('./capability-registry.cjs') as {
|
||||
runtimes: Record<string, { runtime?: { hostIntegration?: unknown } }>;
|
||||
};
|
||||
const declared = runtimes[host]?.runtime?.hostIntegration;
|
||||
if (!declared || typeof declared !== 'object') return 'none';
|
||||
// The descriptor is untrusted JSON; negotiation applies the trust-boundary
|
||||
// invariant (effective ⊆ host-declared ∩ engine-known) and fails closed.
|
||||
const negotiated = hostIntegrationMod.negotiateHostCapabilities(declared);
|
||||
const surface: unknown = negotiated?.effective?.effortSurface;
|
||||
return typeof surface === 'string' ? surface : 'none';
|
||||
} catch {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #488 — Replace or inject the `effort:` value in YAML frontmatter.
|
||||
* Unlike injectEffortFrontmatter (install.js), this overwrites an existing value.
|
||||
|
||||
@@ -45,6 +45,15 @@ const HOST_INTEGRATION_AXES = Object.freeze({
|
||||
transport: Object.freeze(['mcp', 'native-extension'] as const),
|
||||
runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other'] as const),
|
||||
subagentToolkit: Object.freeze(['full', 'read-only'] as const),
|
||||
// ADR-1239 amendment (#2481): how reasoning effort reaches this host.
|
||||
// `argv` — deliverable as an argument on the host's own invocation.
|
||||
// `none` — the host exposes no reasoning-effort mechanism.
|
||||
// `undocumented` is NOT a member here; it is the corpus-wide sentinel above.
|
||||
// A config-file-only surface deliberately has NO vocabulary member: the only
|
||||
// host that ever had one (Gemini CLI's thinkingConfig) was removed as a sunset
|
||||
// runtime in #1928/#1996, and neither its successor Antigravity CLI nor ZCode
|
||||
// documents a reasoning setting. Adding a member with no host would be a guess.
|
||||
effortSurface: Object.freeze(['argv', 'none'] as const),
|
||||
});
|
||||
|
||||
const INTERFACE_POINTS = Object.freeze(['command', 'dispatch', 'model', 'hooks', 'state', 'artifact'] as const);
|
||||
@@ -61,6 +70,7 @@ type StateIO = 'filesystem' | 'sandboxed-storage' | 'session-log-append
|
||||
type Transport = 'mcp' | 'native-extension';
|
||||
type HostRuntime = 'node' | 'bun' | 'sandboxed-web' | 'python' | 'go' | 'rust' | 'electron' | 'other';
|
||||
type SubagentToolkit = 'full' | 'read-only';
|
||||
type EffortSurface = 'argv' | 'none';
|
||||
type DegradationLevel = 'full' | 'degraded' | 'absent';
|
||||
type InterfacePoint = 'command' | 'dispatch' | 'model' | 'hooks' | 'state' | 'artifact';
|
||||
|
||||
@@ -82,6 +92,7 @@ interface HostIntegrationAxes {
|
||||
stateIO: StateIO;
|
||||
transport: Transport;
|
||||
runtime: HostRuntime;
|
||||
effortSurface: EffortSurface;
|
||||
}
|
||||
|
||||
interface DegradationResult {
|
||||
@@ -104,6 +115,7 @@ const SAFE_DEFAULTS: HostIntegrationAxes = {
|
||||
stateIO: 'session-log-append',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
effortSurface: 'none',
|
||||
};
|
||||
|
||||
const PROFILE_BASELINES: Readonly<Record<'programmatic-cli' | 'declarative-cli' | 'ide', HostIntegrationAxes>> =
|
||||
@@ -117,6 +129,7 @@ const PROFILE_BASELINES: Readonly<Record<'programmatic-cli' | 'declarative-cli'
|
||||
stateIO: 'filesystem',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
effortSurface: 'none',
|
||||
} as HostIntegrationAxes),
|
||||
'declarative-cli': Object.freeze({
|
||||
embeddingMode: 'declarative',
|
||||
@@ -127,6 +140,7 @@ const PROFILE_BASELINES: Readonly<Record<'programmatic-cli' | 'declarative-cli'
|
||||
stateIO: 'filesystem',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
effortSurface: 'none',
|
||||
} as HostIntegrationAxes),
|
||||
'ide': Object.freeze({
|
||||
embeddingMode: 'imperative',
|
||||
@@ -137,6 +151,7 @@ const PROFILE_BASELINES: Readonly<Record<'programmatic-cli' | 'declarative-cli'
|
||||
stateIO: 'sandboxed-storage',
|
||||
transport: 'mcp',
|
||||
runtime: 'sandboxed-web',
|
||||
effortSurface: 'none',
|
||||
} as HostIntegrationAxes),
|
||||
});
|
||||
|
||||
@@ -190,6 +205,13 @@ function degradationFor(point: InterfacePoint, axes: Partial<HostIntegrationAxes
|
||||
}
|
||||
|
||||
case 'model': {
|
||||
// NOTE (#2481): the effortSurface axis is deliberately NOT folded into this
|
||||
// level. `modelMode` has graded interface point 3 since Phase A, and every
|
||||
// existing consumer reads it as "can GSD drive model selection". Widening it
|
||||
// to also mean "…and deliver effort" silently redefines an established
|
||||
// contract — an `active` host with no declared effort surface would flip
|
||||
// from `full` to `absent`. Effort is negotiated on its own axis and read
|
||||
// from `effective.effortSurface` by the consumers that care.
|
||||
const mm = (axes as Record<string, unknown>).modelMode;
|
||||
if (mm === 'active') return { level: 'full', fallback: '' };
|
||||
if (mm === 'passive') return { level: 'degraded', fallback: 'instruction-injection / per-agent model field' };
|
||||
@@ -262,6 +284,7 @@ const DEFAULT_ENGINE: EngineCapabilities = {
|
||||
stateIO: 'filesystem',
|
||||
transport: 'mcp',
|
||||
runtime: 'node',
|
||||
effortSurface: 'argv',
|
||||
},
|
||||
known: HOST_INTEGRATION_AXES,
|
||||
};
|
||||
@@ -346,6 +369,14 @@ function negotiateHostCapabilities(
|
||||
if (axis === 'modelMode') {
|
||||
if (hostVal === 'active' && engineVal === 'passive') return 'passive';
|
||||
}
|
||||
// For effortSurface: 'argv' > 'none'. An engine that cannot deliver the
|
||||
// host's richer channel caps the result to what it can drive.
|
||||
if (axis === 'effortSurface') {
|
||||
const RANK: Record<string, number> = { argv: 1, none: 0 };
|
||||
const hr = RANK[hostVal as string] ?? 0;
|
||||
const er = RANK[engineVal as string] ?? 0;
|
||||
if (hr > er) return engineVal;
|
||||
}
|
||||
return hostVal;
|
||||
}
|
||||
|
||||
@@ -357,6 +388,7 @@ function negotiateHostCapabilities(
|
||||
const effectiveStateIO = negotiateScalar('stateIO');
|
||||
const effectiveTransport = negotiateScalar('transport');
|
||||
const effectiveRuntime = negotiateScalar('runtime');
|
||||
const effectiveEffortSurface = negotiateScalar('effortSurface');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch struct negotiation
|
||||
@@ -456,6 +488,7 @@ function negotiateHostCapabilities(
|
||||
stateIO: effectiveStateIO,
|
||||
transport: effectiveTransport,
|
||||
runtime: effectiveRuntime,
|
||||
effortSurface: effectiveEffortSurface,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -222,6 +222,82 @@ export interface RenderedEffort {
|
||||
channel: string | null;
|
||||
}
|
||||
|
||||
// ─── Invocation-time (argv) effort rendering ─────────────────────────────────
|
||||
//
|
||||
// ADR-1239 amendment (#2481) / ADR-443 path (a). EFFORT_RENDERING above covers the
|
||||
// two INSTALL-TIME channels (`frontmatter`, `api`) — effort baked into a generated
|
||||
// artifact. This table covers the INVOCATION-TIME channel: the argument appended to
|
||||
// a host CLI spawned as a subprocess.
|
||||
//
|
||||
// WHETHER to emit is not decided here — it is the negotiated `effortSurface` axis on
|
||||
// the host's descriptor (`argv` | `none`). This table only knows the
|
||||
// syntax for hosts whose surface is `argv`. A host absent from this table renders
|
||||
// null, so an undeclared or undocumented host silently gets nothing rather than a
|
||||
// guessed flag.
|
||||
export interface EffortArgvSpec {
|
||||
/** Render the argv fragment for an already-clamped effort level. */
|
||||
render(level: string): string[];
|
||||
/** Levels this CLI accepts; anything else clamps via `clamp` first. */
|
||||
supported: Set<string>;
|
||||
clamp(level: string): string;
|
||||
}
|
||||
|
||||
export const EFFORT_ARGV: Record<string, EffortArgvSpec> = {
|
||||
// Verified against `claude --help`: `--effort <level>`.
|
||||
claude: {
|
||||
render: (level: string): string[] => ['--effort', level],
|
||||
supported: new Set(['low', 'medium', 'high', 'xhigh', 'max']),
|
||||
clamp: (level: string): string => (level === 'minimal' ? 'low' : level),
|
||||
},
|
||||
// Verified against `opencode run --help`: `--variant` — "model variant
|
||||
// (provider-specific reasoning effort, e.g., high, max, minimal)".
|
||||
opencode: {
|
||||
render: (level: string): string[] => ['--variant', level],
|
||||
supported: new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
|
||||
clamp: (level: string): string => level,
|
||||
},
|
||||
// First-party Codex docs: `model_reasoning_effort` is a config-only key with no
|
||||
// dedicated flag, so the generic `-c key=value` override is the only argv route.
|
||||
codex: {
|
||||
render: (level: string): string[] => ['-c', `model_reasoning_effort=${level}`],
|
||||
supported: new Set(['minimal', 'low', 'medium', 'high', 'xhigh']),
|
||||
clamp: (level: string): string => (level === 'max' ? 'xhigh' : level),
|
||||
},
|
||||
};
|
||||
|
||||
export interface RenderedEffortArgv {
|
||||
argv: string[];
|
||||
value: string | null;
|
||||
host: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the invocation-time effort argument for a host.
|
||||
*
|
||||
* `effortSurface` is the host's negotiated axis value. Only `argv` produces an
|
||||
* argument; `none`, `undocumented`, and anything unrecognised produce nothing.
|
||||
* Never throws.
|
||||
*/
|
||||
export function renderEffortArgv(
|
||||
host: string,
|
||||
universalEffort: string,
|
||||
effortSurface: string | null | undefined,
|
||||
): RenderedEffortArgv {
|
||||
const empty: RenderedEffortArgv = { argv: [], value: null, host };
|
||||
if (effortSurface !== 'argv') return empty;
|
||||
// Own-property lookup only. A plain `EFFORT_ARGV[host]` resolves `__proto__`
|
||||
// (and `constructor`/`toString`) to inherited members, which are truthy but
|
||||
// carry no `clamp`/`render` — a hostile host id would throw instead of
|
||||
// degrading. The host id reaches here from a descriptor, i.e. untrusted JSON.
|
||||
if (typeof host !== 'string' || !Object.prototype.hasOwnProperty.call(EFFORT_ARGV, host)) return empty;
|
||||
const spec = EFFORT_ARGV[host];
|
||||
if (!spec || typeof spec.clamp !== 'function' || typeof spec.render !== 'function') return empty;
|
||||
if (typeof universalEffort !== 'string' || universalEffort.length === 0) return empty;
|
||||
const clamped = spec.clamp(universalEffort);
|
||||
if (!spec.supported.has(clamped)) return empty;
|
||||
return { argv: spec.render(clamped), value: clamped, host };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a universal effort string for a specific runtime.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user