feat(coding-agent): added /extended-context slash command and pass settings to model registry

- Add the `/extended-context` builtin slash command to toggle premium long-context windows.
- Update `ModelRegistry` to accept settings and apply context-window caps using the finalized configuration.
- Add tests covering extended context toggle behavior and session context window capping.
This commit is contained in:
can1357
2026-08-20 08:00:23 +02:00
parent ce94be2b93
commit 57305b99cd
8 changed files with 143 additions and 24 deletions
+1
View File
@@ -11,6 +11,7 @@
- Backgroundable Python — `eval` cells can run async and auto-background like `bash`, with configurable thresholds.
- Local Claude token counting — Anthropic-family tokens now count via a native local tokenizer, and every counter (session maintenance, advisor, stats, context tools) uses the active model's own tokenizer.
- `extendedContext` setting — pick whether models with premium long-context pricing (272K/1M tiers on Codex-class models) use the extended window or compact early and stay on standard pricing.
- `/extended-context` — toggle premium long-context windows without leaving the session.
- Speculative compaction — with `compaction.asyncEnabled`, all compaction modes compact in parallel while the session continues, then splice the result in instantly.
- `tokenizer` property on custom models and `modelOverrides` to pin the tokenizer family for proxy models.
- `qwenTemplateReasoningEffort` in `models.yml` `compat` to disable the Qwen 3.8+ reasoning-effort template parameter for strict local servers.
@@ -108,7 +108,7 @@ export {
import { ModelsConfigFile, type ProviderValidationModel, validateProviderConfiguration } from "./models-config";
import type { ModelOverride, ModelsConfig, ProviderAuthMode } from "./models-config-schema";
import { settings } from "./settings";
import { settings, type Settings } from "./settings";
// DeviceCheck attestation (`x-oai-attestation`) for ChatGPT-OAuth Codex
// requests; the pi-ai provider resolves it just-in-time per request.
@@ -132,21 +132,21 @@ interface CustomModelsResult {
*/
type ModifyModelsHook = (models: Model<Api>[], credentials: OAuthCredentials) => Model<Api>[];
function getDisabledProviderIdsFromSettings(): Set<string> {
function getDisabledProviderIdsFromSettings(settingsInstance?: Settings): Set<string> {
try {
return new Set(settings.get("disabledProviders"));
return new Set((settingsInstance ?? settings).get("disabledProviders"));
} catch {
return new Set();
}
}
/**
* Whether premium long-context windows are enabled. Defaults to true when the
* settings singleton is not initialized (SDK embedding, early boot).
* Whether premium long-context windows are enabled. Defaults to true when no
* settings source is available (SDK embedding, early boot).
*/
function isExtendedContextEnabledFromSettings(): boolean {
function isExtendedContextEnabledFromSettings(settingsInstance?: Settings): boolean {
try {
return settings.get("extendedContext");
return (settingsInstance ?? settings).get("extendedContext");
} catch {
return true;
}
@@ -209,6 +209,7 @@ export class ModelRegistry {
#runtimeModelManagers: Map<string, { options: ModelManagerOptions<Api>; sourceId: string }> = new Map();
#ignoreLocalModelConfig: boolean;
#fetch: FetchImpl;
#settings: Settings | undefined;
#resolveCommandBackedApiKey(provider: string, options?: { forceCommandRefresh?: boolean }): CommandApiKeyResolution {
const keyConfig = this.#customProviderApiKeys.get(provider);
@@ -252,10 +253,13 @@ export class ModelRegistry {
* must never apply client-side credential or routing overrides.
*/
ignoreLocalModelConfig?: boolean;
/** Settings source for availability and context-window policies. */
settings?: Settings;
fetch?: FetchImpl;
},
) {
this.#ignoreLocalModelConfig = options?.ignoreLocalModelConfig ?? false;
this.#settings = options?.settings;
this.#fetch =
options?.fetch ??
(isBunTestRuntime()
@@ -886,7 +890,7 @@ export class ModelRegistry {
}
#addImplicitDiscoverableProviders(configuredProviders: Set<string>): void {
const disabledProviders = getDisabledProviderIdsFromSettings();
const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
if (!configuredProviders.has("ollama") && !disabledProviders.has("ollama")) {
this.#discoverableProviders.push({
provider: "ollama",
@@ -1058,7 +1062,7 @@ export class ModelRegistry {
strategy: ModelRefreshStrategy,
providerFilter?: ReadonlySet<string>,
): Promise<void> {
const disabledProviders = getDisabledProviderIdsFromSettings();
const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
const selectedDiscoverableProviders = (
providerFilter
? this.#discoverableProviders.filter(provider => providerFilter.has(provider.provider))
@@ -1367,7 +1371,7 @@ export class ModelRegistry {
}),
},
];
const disabledProviders = getDisabledProviderIdsFromSettings();
const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(descriptor => {
if (disabledProviders.has(descriptor.providerId)) return false;
if (configuredDiscoveryProviders.has(descriptor.providerId)) return false;
@@ -1592,7 +1596,7 @@ export class ModelRegistry {
});
}
#applyHardcodedModelPolicies(models: Model<Api>[]): Model<Api>[] {
const extendedContext = isExtendedContextEnabledFromSettings();
const extendedContext = isExtendedContextEnabledFromSettings(this.#settings);
return models.map(model => {
// Extended context off: cap models with a premium long-context price
// tier (e.g. GPT-5.6 bills 2x input above 272K) at the standard-pricing
@@ -1683,7 +1687,7 @@ export class ModelRegistry {
* full bundled catalog (thousands of models, ~50 providers).
*/
#createProviderAvailabilityCheck(): (provider: string) => boolean {
const disabledProviders = getDisabledProviderIdsFromSettings();
const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
const byProvider = new Map<string, boolean>();
return provider => {
let available = byProvider.get(provider);
@@ -1752,7 +1756,7 @@ export class ModelRegistry {
}
getDiscoverableProviders(): string[] {
const disabledProviders = getDisabledProviderIdsFromSettings();
const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
return this.#discoverableProviders
.filter(provider => !disabledProviders.has(provider.provider))
.map(provider => provider.provider);
@@ -1770,7 +1774,7 @@ export class ModelRegistry {
hasProvider(providerId: string): boolean {
const providerModels = this.#hasFullSnapshot ? this.#models : this.#composeStaticModels(new Set([providerId]));
if (providerModels.some(model => model.provider === providerId)) return true;
if (getDisabledProviderIdsFromSettings().has(providerId)) return false;
if (getDisabledProviderIdsFromSettings(this.#settings).has(providerId)) return false;
return (
this.#discoverableProviders.some(provider => provider.provider === providerId) ||
this.#runtimeModelManagers.has(providerId)
+10 -4
View File
@@ -1289,9 +1289,9 @@ export async function runRootCommand(
// tree; declare it so headless subagent optimizations (e.g. skipping replan
// title refresh) can tell a focusable process from a print/RPC/eval one.
setInteractiveHost(isInteractive);
// Create AuthStorage and ModelRegistry upfront. A configured-but-unreachable
// auth broker throws here; convert it to an actionable stderr message + clean
// exit instead of a raw uncaught stack trace (issue #8096).
// Create AuthStorage upfront. A configured-but-unreachable auth broker throws
// here; convert it to an actionable stderr message + clean exit instead of a
// raw uncaught stack trace (issue #8096).
let authStorage: AuthStorage;
try {
authStorage = await logger.time("discoverAuthStorage", deps.discoverAuthStorage ?? discoverAuthStorage);
@@ -1301,7 +1301,6 @@ export async function runRootCommand(
process.stderr.write(`${chalk.red(`Error: ${message}`)}\n`);
process.exit(1);
}
const modelRegistry = logger.time("modelRegistry:init", () => new ModelRegistry(authStorage));
const settingsInstance =
deps.settings ?? (await logger.time("settings:init", Settings.init, { cwd, configFiles: parsedArgs.config }));
@@ -1319,6 +1318,13 @@ export async function runRootCommand(
} else if (parsedArgs.mode === "acp") {
applyAcpDefaultSettingOverrides(settingsInstance);
}
// The registry composes policy-dependent metadata synchronously, including
// extended-context window caps, so it must receive the finalized settings.
const modelRegistry = logger.time(
"modelRegistry:init",
() => new ModelRegistry(authStorage, undefined, { settings: settingsInstance }),
);
if (parsedArgs.noPty || parsedArgs.mode === "rpc-ui") {
Bun.env.PI_NO_PTY = "1";
}
+8 -5
View File
@@ -1241,12 +1241,19 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
registerSshCleanup();
registerEvalCleanup();
const settings = await (options.settings ??
options.settingsManager ??
logger.time("settings", Settings.init, { cwd, agentDir }));
logger.time("initializeWithSettings", initializeWithSettings, settings);
// Pin authStorage to modelRegistry.authStorage: ModelRegistry.getApiKey() routes refresh
// failures through that instance, so any divergent storage handed to the bridge / mcpManager
// / session would silently miss credential_disabled events.
const modelRegistry =
options.modelRegistry ??
new ModelRegistry(options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir)));
new ModelRegistry(options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir)), undefined, {
settings,
});
// Track whether we internally created the authStorage so we can close it
// if construction fails before the session takes ownership.
const ownsAuthStorage = !options.authStorage && !options.modelRegistry;
@@ -1270,10 +1277,6 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
startupCredentialDisabledEvents.push(event);
}
});
const settings = await (options.settings ??
options.settingsManager ??
logger.time("settings", Settings.init, { cwd, agentDir }));
logger.time("initializeWithSettings", initializeWithSettings, settings);
if (!options.modelRegistry) {
modelRegistry.refreshInBackground();
}
@@ -5,7 +5,7 @@ import {
getModelMatchPreferences,
resolveCliModel,
} from "../config/model-resolver";
import type { SettingPath } from "../config/settings";
import type { SettingPath, Settings } from "../config/settings";
import { describeLoopLimitRuntime } from "../modes/loop-limit";
import type { InteractiveModeContext } from "../modes/types";
import type { AgentSession } from "../session/agent-session";
@@ -54,6 +54,32 @@ function formatFastModeStatus(session: AgentSession): string {
return session.isFastModeEnabled() ? "on" : "off";
}
/** `/extended-context status` label for the premium long-context window setting. */
function formatExtendedContextStatus(settings: Settings): string {
return settings.get("extendedContext") ? "on" : "off";
}
/** Applies an `/extended-context` argument and returns its operator feedback. */
function applyExtendedContextCommand(settings: Settings, args: string): string | undefined {
const arg = args.trim().toLowerCase();
const current = settings.get("extendedContext");
if (!arg || arg === "toggle") {
const enabled = !current;
settings.set("extendedContext", enabled);
return `Extended context ${enabled ? "enabled" : "disabled"}.`;
}
if (arg === "on") {
settings.set("extendedContext", true);
return "Extended context enabled.";
}
if (arg === "off") {
settings.set("extendedContext", false);
return "Extended context disabled.";
}
if (arg === "status") return `Extended context is ${formatExtendedContextStatus(settings)}.`;
return undefined;
}
/** Detailed, session-effective `/computer status` diagnostics. */
async function formatComputerUseStatus(session: AgentSession): Promise<string> {
const enabled = session.settings.get("computer.enabled");
@@ -433,6 +459,32 @@ export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
runtime.ctx.editor.setText("");
},
},
{
name: "extended-context",
description: "Toggle premium long-context windows",
acpDescription: "Toggle extended context",
acpInputHint: "[on|off|status]",
subcommands: [
{ name: "on", description: "Enable premium long-context windows" },
{ name: "off", description: "Use standard-pricing context windows" },
{ name: "status", description: "Show extended context status" },
],
allowArgs: true,
getTuiAutocompleteDescription: runtime =>
`Extended context: ${formatExtendedContextStatus(runtime.ctx.settings)}`,
handle: async (command, runtime) => {
const output = applyExtendedContextCommand(runtime.settings, command.args);
if (!output) return usage("Usage: /extended-context [on|off|status]", runtime);
await runtime.output(output);
return commandConsumed();
},
handleTui: (command, runtime) => {
const output = applyExtendedContextCommand(runtime.ctx.settings, command.args);
refreshStatusLine(runtime.ctx);
runtime.ctx.showStatus(output ?? "Usage: /extended-context [on|off|status]");
runtime.ctx.editor.setText("");
},
},
{
name: "computer",
description: "Toggle the native computer-use tool for this session",
@@ -243,6 +243,24 @@ describe("ACP builtin slash commands", () => {
expect(output).toEqual(["Fast mode is off."]);
});
it("toggles extended context with explicit controls and reports state", async () => {
const { output, runtime } = createRuntime();
expect(await executeAcpBuiltinSlashCommand("/extended-context off", runtime)).toEqual({ consumed: true });
expect(runtime.settings.get("extendedContext")).toBe(false);
expect(await executeAcpBuiltinSlashCommand("/extended-context on", runtime)).toEqual({ consumed: true });
expect(runtime.settings.get("extendedContext")).toBe(true);
expect(await executeAcpBuiltinSlashCommand("/extended-context", runtime)).toEqual({ consumed: true });
expect(runtime.settings.get("extendedContext")).toBe(false);
expect(await executeAcpBuiltinSlashCommand("/extended-context status", runtime)).toEqual({ consumed: true });
expect(output).toEqual([
"Extended context disabled.",
"Extended context enabled.",
"Extended context disabled.",
"Extended context is off.",
]);
});
it("forces a tool and returns remaining prompt text", async () => {
const { output, runtime } = createRuntime();
@@ -47,6 +47,7 @@ describe("buildAvailableSlashCommands", () => {
expect(byName["reset-usage"]).toBeUndefined();
expect(byName.fast.description).toBe("Toggle fast mode");
expect(byName["extended-context"].description).toBe("Toggle extended context");
expect(byName["ext:hello"].description).toBe("Extension hello");
expect(byName["custom:hello"].description).toBe("Custom hello");
expect(byName["server:prompt"].description).toBe("MCP prompt");
@@ -1029,6 +1029,40 @@ describe("createAgentSession deferred model pattern resolution", () => {
}
});
test("caps premium Codex context before a new session starts", async () => {
const authStorage = createInMemoryAuthStorage();
authStoragesToClose.push(authStorage);
authStorage.setRuntimeApiKey("openai-codex", "codex-oauth-token");
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
authStorage,
settings: Settings.isolated({ extendedContext: false }),
sessionManager: SessionManager.inMemory(),
disableExtensionDiscovery: true,
skills: [],
contextFiles: [],
promptTemplates: [],
slashCommands: [],
enableMCP: false,
enableLsp: false,
skipPythonPreflight: true,
rules: [],
preloadedCustomToolPaths: [],
toolNames: ["read"],
modelPattern: "openai-codex/gpt-5.6-sol",
});
try {
expect(session.model?.provider).toBe("openai-codex");
expect(session.model?.id).toBe("gpt-5.6-sol");
expect(session.model?.contextWindow).toBe(272_000);
} finally {
await session.dispose();
}
});
test("restores role model max selector from extension provider after startup resume", async () => {
const defaultModel = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!defaultModel) {