Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4246,6 +4246,32 @@ <h2>Ambient behavior</h2>
<h2>Models &amp; browsing</h2>
<p>The models, browser runtime, and organization context available on future turns.</p>
</div>
<section class="card setting-row hidden" id="card-interactive-fast-mode">
<div class="head">
<h2>Fast mode for interactive turns</h2>
<p>
Org-wide, default off. When on, human turns run in fast mode on fast-capable models unless the
turn asks otherwise. Requires fast-mode quota with the provider.
</p>
</div>
<div class="body">
<label class="setting-toggle">
<input type="checkbox" id="interactive-fast-mode" />
<span class="setting-switch" aria-hidden="true"></span>
<span class="setting-copy"
><strong>Default interactive turns to fast mode</strong
><small
>A turn that picks its own fast-mode setting (for example the web UI toggle) still
wins.</small
></span
>
</label>
</div>
<div class="foot">
<button class="primary" data-save="interactive-fast-mode">Apply</button
><span class="status" id="st-interactive-fast-mode"></span>
</div>
</section>
<section class="card setting-row hidden" id="card-base-model">
<div class="head">
<h2>Default harness and model</h2>
Expand Down Expand Up @@ -6212,6 +6238,9 @@ <h2 id="governance-review-title">Confirm governance change</h2>
const showOrgAmbient = scope.startsWith("org:") && "orgAmbient" in r.data;
$("card-org-ambient").classList.toggle("hidden", !showOrgAmbient);
if (showOrgAmbient) $("org-ambient").checked = r.data.orgAmbient !== false;
const showInteractiveFastMode = scope.startsWith("org:") && "interactiveFastMode" in r.data;
$("card-interactive-fast-mode").classList.toggle("hidden", !showInteractiveFastMode);
if (showInteractiveFastMode) $("interactive-fast-mode").checked = r.data.interactiveFastMode === true;

const opts = r.data.baseModelOptions || [];
const dflt = r.data.baseModelDefault || "";
Expand Down Expand Up @@ -7694,6 +7723,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
egress: () => collectEgress(),
"external-slack-participants": () => ({ on: $("external-slack-participants").checked }),
"org-ambient": () => ({ on: $("org-ambient").checked }),
"interactive-fast-mode": () => ({ on: $("interactive-fast-mode").checked }),
runtime: () => ({ harnessId: $("base-harness").value, modelId: $("base-model").value }),
"approved-harnesses": () => ({
ids: Array.from(document.querySelectorAll("#approved-harnesses-list input[type=checkbox]:checked")).map(
Expand Down Expand Up @@ -7727,6 +7757,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
egress: "st-egress",
"external-slack-participants": "st-external-slack-participants",
"org-ambient": "st-org-ambient",
"interactive-fast-mode": "st-interactive-fast-mode",
runtime: "st-runtime",
"approved-harnesses": "st-approved-harnesses",
"webui-models": "st-webui-models",
Expand Down
4 changes: 3 additions & 1 deletion plugins/web-ui/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,9 @@ function currentTurnOptions(): TurnOptions {
const { harnessId: harness } = currentModelOption();
return {
...(harnessSupportsEffort(harness) ? { effortLevel: composerState.effortLevel } : {}),
...(harnessSupportsFastMode(harness) ? { fastMode: composerState.fastMode } : {}),
...(harnessSupportsFastMode(harness) && typeof composerState.fastMode === "boolean"
? { fastMode: composerState.fastMode }
: {}),
harness,
scopeId: chatState.scopeId,
channelName: chatState.contextName,
Expand Down
19 changes: 13 additions & 6 deletions plugins/web-ui/src/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,15 @@ function modelOptionFor(value: ModelOptionValue): ModelOption {
);
}

function loadStoredFastMode(): boolean {
function loadStoredFastMode(): boolean | undefined {
try {
const stored = localStorage.getItem(FAST_MODE_STORAGE_KEY);
if (stored === "0") return false;
if (stored === "1") return true;
} catch {
void 0;
}
return true;
return undefined;
}

function loadStoredEffort(fallback: EffortLevel): EffortLevel {
Expand Down Expand Up @@ -209,6 +209,11 @@ let dragDepth = 0;
let skillsLoading = false;
let slashActiveIndex = 0;
let fastModeCharging = false;
let orgFastModeDefault = false;

function effectiveFastMode(): boolean {
return composerState.fastMode ?? orgFastModeDefault;
}
let fastModeChargeTimer: ReturnType<typeof setTimeout> | null = null;

export function resetComposer(): void {
Expand Down Expand Up @@ -242,6 +247,7 @@ export async function refreshRuntimeSelection(scopeId: string | null, agent?: Ag
}
activeRuntimeConfig = config;
setFastModeModelIds(config.fastModeModelIds);
orgFastModeDefault = config.interactiveFastMode === true;
applyRuntimeOptions(config.approvedHarnesses, config.modelsByHarness, config.effective, config.modelCatalog);
if (agent && (!chatState.threadRef || !threadModelPicks.has(chatState.threadRef)))
agent.state.model = currentModelOption().model;
Expand All @@ -260,6 +266,7 @@ async function changeScopeRuntime(
if (request !== runtimeRequest || scopeId !== chatState.scopeId) return;
activeRuntimeConfig = config;
setFastModeModelIds(config.fastModeModelIds);
orgFastModeDefault = config.interactiveFastMode === true;
applyRuntimeOptions(config.approvedHarnesses, config.modelsByHarness, config.effective, config.modelCatalog);
if (!chatState.threadRef || !threadModelPicks.has(chatState.threadRef))
agent.state.model = currentModelOption().model;
Expand All @@ -285,7 +292,7 @@ export function composerForm(agent: Agent): TemplateResult {
const effortAvailable = harnessSupportsEffort(selectedModel.harnessId);
const fastSupported = harnessSupportsFastMode(selectedModel.harnessId);
const fastAvailable = fastSupported && modelSupportsFastMode(selectedModel.model.id);
const fastOn = fastAvailable && composerState.fastMode;
const fastOn = fastAvailable && effectiveFastMode();
const fastCharging = fastModeCharging && fastOn;
let fastTitle = "Fast mode is only available on Opus models";
if (fastAvailable) fastTitle = fastOn ? "Fast mode active" : "Fast mode";
Expand Down Expand Up @@ -676,7 +683,7 @@ function composerApprovalPanel(approvals: PendingApproval[]): TemplateResult {
function settingsControl(agent: Agent, selected: ModelOption, disabled: boolean): TemplateResult {
const open = composerState.openMenu === "settings";
const fastAvailable = harnessSupportsFastMode(selected.harnessId) && modelSupportsFastMode(selected.model.id);
const fastOn = fastAvailable && composerState.fastMode;
const fastOn = fastAvailable && effectiveFastMode();
const summary = `${selected.buttonLabel} · ${effortLabel(composerState.effortLevel)}${fastOn ? " · Fast" : ""}`;
return html`
<div class="menu-control settings-control ${open ? "open" : ""}" data-align="right">
Expand Down Expand Up @@ -1305,13 +1312,13 @@ function selectEffort(level: EffortLevel, agent: Agent): void {
function toggleFastMode(agent: Agent): void {
if (hasUnresolvedApproval() || chatState.resolvingApprovals.size > 0) return;
if (!modelSupportsFastMode(currentModelOption().model.id)) return;
composerState.fastMode = !composerState.fastMode;
composerState.fastMode = !effectiveFastMode();
persistPreference(FAST_MODE_STORAGE_KEY, composerState.fastMode ? "1" : "0");
if (fastModeChargeTimer) {
clearTimeout(fastModeChargeTimer);
fastModeChargeTimer = null;
}
fastModeCharging = composerState.fastMode;
fastModeCharging = composerState.fastMode === true;
drawActiveChat(agent);
if (fastModeCharging) {
fastModeChargeTimer = setTimeout(() => {
Expand Down
1 change: 1 addition & 0 deletions plugins/web-ui/src/core-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ export interface RuntimeConfig {
effective: { harnessId: string; modelId: string };
upgradeAvailable: boolean;
fastModeModelIds?: string[];
interactiveFastMode?: boolean;
}

export async function fetchRuntimeConfig(scopeId?: string | null): Promise<RuntimeConfig | null> {
Expand Down
17 changes: 17 additions & 0 deletions src/api/routes/admin-resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,23 @@ export const ADMIN_RESOURCES: readonly AdminResource[] = [
(deps, _scope, on) => deps.config!.setOrgAmbient(on),
),
},
{
id: "interactive-fast-mode",
kind: "boolean",
target: "org",
label:
"Fast mode for interactive turns org-wide: on means human turns run in fast mode on fast-capable models unless the turn asks otherwise. Requires fast-mode quota with the provider.",
readKey: "interactiveFastMode",
get: (deps, scope) => (parseScopeId(scope).kind === "org" ? deps.config!.getInteractiveFastMode() : undefined),
apply: generic<boolean>(
(body, { scope }) => {
const bad = orgOnly(scope, "the interactive fast-mode switch is org-wide");
if (bad) return bad;
return boolBody(body);
},
(deps, _scope, on) => deps.config!.setInteractiveFastMode(on),
),
},
{
id: "base-model",
kind: "enum",
Expand Down
1 change: 1 addition & 0 deletions src/api/routes/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,7 @@ async function runtimeConfigBody(ctx: ApiCtx, scope: ScopeId): Promise<Record<st
effective: { harnessId: effective.harnessId, modelId: effective.modelId },
upgradeAvailable: Boolean(scopeOverride && scopeOverride.orgRevision !== orgDefault.revision),
fastModeModelIds: FAST_MODE_MODEL_IDS,
interactiveFastMode: await config.getInteractiveFastModeDurable(),
};
}

Expand Down
6 changes: 5 additions & 1 deletion src/core/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
} from "../types.ts";
import { scopeId as toScopeId, personalScope } from "../types.ts";
import { turnOriginRequestFields } from "./turn-origin.ts";
import { resolveTurnFastMode } from "./turn-options.ts";
import { orgId } from "../config.ts";
import { renderGatewayContext } from "./gateway-context.ts";
import { deriveTurnOutcome, approvalBlocksInput } from "./turn-outcome.ts";
Expand Down Expand Up @@ -2032,6 +2033,9 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
? Math.min(requestedTurnWallClockMs, configuredTurnWallClockMs)
: requestedTurnWallClockMs;
}
const wantsOrgFastMode =
typeof input.fastMode !== "boolean" && humanTurn && (await deps.config?.getInteractiveFastModeDurable());
const effectiveFastMode = resolveTurnFastMode(input.fastMode, humanTurn, wantsOrgFastMode === true);
const runHarnessTurn = (
harnessInput: string,
extras: {
Expand Down Expand Up @@ -2069,7 +2073,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
...(input.harness ? { harness: input.harness } : {}),
...(input.model ? { model: input.model } : {}),
...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}),
...(typeof input.fastMode === "boolean" ? { fastMode: input.fastMode } : {}),
...(typeof effectiveFastMode === "boolean" ? { fastMode: effectiveFastMode } : {}),
...(strictReadOnly ? { readOnly: true } : {}),
...(input.surfaceTools && surfaceToolDeps
? {
Expand Down
9 changes: 9 additions & 0 deletions src/core/turn-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ import {
export const NON_INTERACTIVE_THINKING_LEVEL = "xhigh";
export const NON_INTERACTIVE_FAST_MODE = false;

export function resolveTurnFastMode(
requested: boolean | undefined,
humanTurn: boolean,
interactiveDefault: boolean,
): boolean | undefined {
if (typeof requested === "boolean") return requested;
return humanTurn && interactiveDefault ? true : undefined;
}

export function turnModelOptions(input: { triggered?: boolean; thinkingLevel?: string; fastMode?: boolean }): {
thinkingLevel?: string;
fastMode?: boolean;
Expand Down
20 changes: 19 additions & 1 deletion src/resolution/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ export interface ScopedConfigStore {
getOrgAmbient(): boolean;
setOrgAmbient(on: boolean): void;
getOrgAmbientDurable(): Promise<boolean>;
getInteractiveFastMode(): boolean;
setInteractiveFastMode(on: boolean): void;
getInteractiveFastModeDurable(): Promise<boolean>;
getBaseModelOwnDurable(id: ScopeId): Promise<string | null>;
getWebuiModels(id: ScopeId): string[] | null;
setWebuiModels(id: ScopeId, ids: string[] | null): void;
Expand Down Expand Up @@ -209,6 +212,7 @@ export function createMemoryConfigStore(
baseModels?: DurableMap<PersistedBaseModel>;
approvedHarnesses?: DurableMap<PersistedApprovedHarnesses>;
orgAmbient?: DurableMap<PersistedScopedFlag>;
interactiveFastMode?: DurableMap<PersistedScopedFlag>;
webuiModels?: DurableMap<PersistedWebuiModels>;
peopleDirectoryUrls?: DurableMap<PersistedPeopleDirectoryUrl>;
branding?: DurableMap<PersistedBranding>;
Expand All @@ -232,6 +236,7 @@ export function createMemoryConfigStore(
const baseModels = new Map<ScopeId, PersistedBaseModel>();
let approvedHarnesses: string[] | null = null;
let orgAmbient = true;
let interactiveFastMode = false;
const webuiModels = new Map<ScopeId, string[]>();
const peopleDirectoryUrls = new Map<ScopeId, string>();
const branding = new Map<ScopeId, OrgBranding>();
Expand All @@ -249,6 +254,7 @@ export function createMemoryConfigStore(
const baseModelStore = opts.baseModels ?? createMemoryMap<PersistedBaseModel>();
const approvedHarnessStore = opts.approvedHarnesses ?? createMemoryMap<PersistedApprovedHarnesses>();
const orgAmbientStore = opts.orgAmbient ?? createMemoryMap<PersistedScopedFlag>();
const interactiveFastModeStore = opts.interactiveFastMode ?? createMemoryMap<PersistedScopedFlag>();
const webuiModelStore = opts.webuiModels ?? createMemoryMap<PersistedWebuiModels>();
const peopleDirectoryUrlStore = opts.peopleDirectoryUrls ?? createMemoryMap<PersistedPeopleDirectoryUrl>();
const brandingStore = opts.branding ?? createMemoryMap<PersistedBranding>();
Expand Down Expand Up @@ -377,6 +383,7 @@ export function createMemoryConfigStore(
for (const r of await baseModelStore.all()) baseModels.set(r.scopeId, r);
approvedHarnesses = (await approvedHarnessStore.get(org))?.ids ?? null;
orgAmbient = (await orgAmbientStore.get(org))?.on ?? true;
interactiveFastMode = (await interactiveFastModeStore.get(org))?.on ?? false;
for (const r of await webuiModelStore.all()) webuiModels.set(r.scopeId, r.ids);
for (const r of await peopleDirectoryUrlStore.all()) peopleDirectoryUrls.set(r.scopeId, r.url);
for (const r of await brandingStore.all()) branding.set(r.scopeId, r.branding);
Expand Down Expand Up @@ -712,6 +719,14 @@ export function createMemoryConfigStore(
persist(`orgAmbient:${org}`, "org ambient switch", () => orgAmbientStore.put(org, { scopeId: org, on }));
},
getOrgAmbientDurable: async () => (await orgAmbientStore.get(org))?.on ?? true,
getInteractiveFastMode: () => interactiveFastMode,
setInteractiveFastMode(on) {
interactiveFastMode = on;
persist(`interactiveFastMode:${org}`, "interactive fast mode switch", () =>
interactiveFastModeStore.put(org, { scopeId: org, on }),
);
},
getInteractiveFastModeDurable: async () => (await interactiveFastModeStore.get(org))?.on ?? false,
getBaseModelOwnDurable: async (id) => (await baseModelStore.get(id))?.modelId ?? null,
getBaseModelDurable: async (id) =>
(await baseModelStore.get(id))?.modelId ??
Expand Down Expand Up @@ -859,6 +874,7 @@ export function createMemoryConfigStore(
approved,
brandingRow,
orgAmbientRow,
interactiveFastModeRow,
] = await Promise.all([
soulStore.get(id),
commandPolicyStore.get(id),
Expand All @@ -871,6 +887,7 @@ export function createMemoryConfigStore(
id === org ? approvedHarnessStore.get(org) : null,
brandingStore.get(id),
id === org ? orgAmbientStore.get(org) : null,
id === org ? interactiveFastModeStore.get(org) : null,
]);
let refreshedSoul = soul;
const legacyHistory = legacySoulHistory.get(id) ?? [];
Expand Down Expand Up @@ -908,6 +925,7 @@ export function createMemoryConfigStore(
else baseModels.delete(id);
if (id === org) approvedHarnesses = approved?.ids ?? null;
if (id === org) orgAmbient = orgAmbientRow?.on ?? true;
if (id === org) interactiveFastMode = interactiveFastModeRow?.on ?? false;
if (brandingRow) branding.set(id, brandingRow.branding);
else branding.delete(id);
},
Expand All @@ -922,7 +940,7 @@ export function createMemoryConfigStore(
`model:${id}`,
`turnWallClock:${id}`,
`branding:${id}`,
...(id === org ? [`approvedHarnesses:${org}`, `orgAmbient:${org}`] : []),
...(id === org ? [`approvedHarnesses:${org}`, `orgAmbient:${org}`, `interactiveFastMode:${org}`] : []),
];
await Promise.all(
keys.map(async (key) => {
Expand Down
1 change: 1 addition & 0 deletions src/wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ export function buildApp(
baseModels: artifactMap<PersistedBaseModel>("base_model_configs"),
approvedHarnesses: artifactMap<PersistedApprovedHarnesses>("approved_harness_configs"),
orgAmbient: artifactMap<PersistedScopedFlag>("org_ambient_flag"),
interactiveFastMode: artifactMap<PersistedScopedFlag>("interactive_fast_mode_flag"),
webuiModels: artifactMap<PersistedWebuiModels>("webui_model_configs"),
peopleDirectoryUrls: artifactMap<PersistedPeopleDirectoryUrl>("people_directory_urls"),
branding: artifactMap<PersistedBranding>("branding_configs"),
Expand Down
41 changes: 41 additions & 0 deletions test/interactive-fast-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createMemoryMap } from "../src/persistence/durable-map.ts";
import { createMemoryConfigStore, type PersistedScopedFlag } from "../src/resolution/config-store.ts";
import { scopeId } from "../src/types.ts";
import { resolveTurnFastMode } from "../src/core/turn-options.ts";

test("interactive fast mode defaults off and persists across instances", async () => {
const interactiveFastMode = createMemoryMap<PersistedScopedFlag>();
const first = createMemoryConfigStore("org", { interactiveFastMode });
await first.hydrate!();
assert.equal(first.getInteractiveFastMode(), false);
assert.equal(await first.getInteractiveFastModeDurable(), false);

first.setInteractiveFastMode(true);
assert.equal(first.getInteractiveFastMode(), true);
await first.flushScope(scopeId("org", "org"));

const second = createMemoryConfigStore("org", { interactiveFastMode });
await second.hydrate!();
assert.equal(second.getInteractiveFastMode(), true);
assert.equal(await second.getInteractiveFastModeDurable(), true);
});

test("the org default reaches only human turns that expressed no preference", () => {
assert.equal(resolveTurnFastMode(undefined, true, true), true);
assert.equal(resolveTurnFastMode(undefined, true, false), undefined);
assert.equal(resolveTurnFastMode(undefined, false, true), undefined);
assert.equal(resolveTurnFastMode(false, true, true), false);
assert.equal(resolveTurnFastMode(true, false, false), true);
});

test("refreshScope picks up an interactive fast mode change written elsewhere", async () => {
const interactiveFastMode = createMemoryMap<PersistedScopedFlag>();
const store = createMemoryConfigStore("org", { interactiveFastMode });
await store.hydrate!();
const org = scopeId("org", "org");
await interactiveFastMode.put(org, { scopeId: org, on: true });
await store.refreshScope(org);
assert.equal(store.getInteractiveFastMode(), true);
});