diff --git a/.github/pr-screenshots/admin-runtime-controls.jpg b/.github/pr-screenshots/admin-runtime-controls.jpg new file mode 100644 index 000000000..df5192ce9 Binary files /dev/null and b/.github/pr-screenshots/admin-runtime-controls.jpg differ diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index e7c0367b0..35651f251 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -4348,6 +4348,15 @@

Default harness and model

+ + +
Confirm governance change harness.appendChild(o); }); harness.value = approvedHarnesses.includes(current.harnessId) ? current.harnessId : approvedHarnesses[0]; + const effort = $("base-effort"); + const thinkingLevelsByHarness = r.data.thinkingLevelsByHarness || {}; + const syncEffort = () => { + const thinkingLevels = thinkingLevelsByHarness[harness.value] || ["auto"]; + const prior = effort.value || current.effortLevel || "auto"; + effort.textContent = ""; + thinkingLevels.forEach((level) => { + const o = document.createElement("option"); + o.value = level; + o.textContent = + { + auto: "Auto", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra high", + max: "Max", + ultracode: "Ultracode", + }[level] || level; + effort.appendChild(o); + }); + effort.value = thinkingLevels.includes(prior) ? prior : "auto"; + }; + const fastMode = $("base-fast-mode"); + fastMode.checked = current.fastMode === true; + const syncFastMode = () => { + const fastCapable = + (r.data.fastModeHarnessIds || []).includes(harness.value) && + (r.data.fastModeModelIds || []).includes($("base-model").value); + fastMode.disabled = !fastCapable; + if (!fastCapable) fastMode.checked = false; + }; const syncModels = () => { const selectedHarness = harness.value; const compatible = modelsByHarness[selectedHarness] || opts; @@ -6352,6 +6393,9 @@

Confirm governance change

sel.appendChild(o); }); sel.value = compatible.some((m) => m.id === prior) ? prior : (compatible[0] || {}).id || ""; + sel.oninput = syncFastMode; + syncEffort(); + syncFastMode(); }; harness.oninput = syncModels; syncModels(); @@ -7937,7 +7981,12 @@

Confirm governance change

"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 }), + runtime: () => ({ + harnessId: $("base-harness").value, + modelId: $("base-model").value, + effortLevel: $("base-effort").value, + fastMode: $("base-fast-mode").checked, + }), "approved-harnesses": () => ({ ids: Array.from(document.querySelectorAll("#approved-harnesses-list input[type=checkbox]:checked")).map( (c) => c.value, diff --git a/plugins/admin/test/default-view.test.ts b/plugins/admin/test/default-view.test.ts index 68543ffe0..634a7bd77 100644 --- a/plugins/admin/test/default-view.test.ts +++ b/plugins/admin/test/default-view.test.ts @@ -157,6 +157,18 @@ test("governance renders simple settings as compact rows with contextual actions assert.match(html, /"turnWallClockSec" in r\.data/); }); +test("default runtime controls save reasoning level and fast mode", () => { + assert.match(html, /id="base-effort"/); + assert.match(html, /id="base-fast-mode"/); + assert.match(html, /thinkingLevelsByHarness/); + assert.match(html, /fastModeModelIds/); + assert.match(html, /fastModeHarnessIds/); + assert.match( + html, + /runtime: \(\) => \(\{[\s\S]*effortLevel: \$\("base-effort"\)\.value,[\s\S]*fastMode: \$\("base-fast-mode"\)\.checked/, + ); +}); + test("compact governance rows preserve policy detail and collapse before they overflow", () => { assert.doesNotMatch(html, /#view-governance \.setting-row > \.head p[^}]*line-clamp/); assert.doesNotMatch(html, /#view-governance \.setting-row > \.foot \.status[^}]*white-space:\s*nowrap/); diff --git a/src/api/routes/admin-resources.ts b/src/api/routes/admin-resources.ts index 6d3c2f22f..1ffeb83d7 100644 --- a/src/api/routes/admin-resources.ts +++ b/src/api/routes/admin-resources.ts @@ -5,6 +5,8 @@ import { parseCommandPolicy } from "../../policy/command-policy.ts"; import { parseScopeId, scopeId, type CommandPolicy, type Grant } from "../../types.ts"; import { defaultModelForHarness, + FAST_MODE_MODEL_IDS, + harnessSupportsFastMode, HARNESS_IDS, isHarnessId, modelSupportedByHarness, @@ -12,6 +14,7 @@ import { modelProviderAvailabilityFor, resolveModel, SELECTABLE_BASE_MODELS, + thinkingLevelsForHarness, ALL_PROVIDERS_AVAILABLE, } from "../../model/pi-models.ts"; import { resolveRuntimeChoiceDurable } from "../../harness/harness-router.ts"; @@ -372,7 +375,19 @@ export const ADMIN_RESOURCES: readonly AdminResource[] = [ return { error: `model ${modelId} is not supported by ${runtime.harnessId}` }; const bad = unserviceable(runtime.harnessId); if (bad) return bad; - await ctx.deps.config!.setRuntimeSelectionLatest(scope, { harnessId: runtime.harnessId, modelId }); + await ctx.deps.config!.setRuntimeSelectionLatest(scope, { + harnessId: runtime.harnessId, + modelId, + ...(runtime.effortLevel ? { effortLevel: runtime.effortLevel } : {}), + ...(typeof runtime.fastMode === "boolean" + ? { + fastMode: + runtime.fastMode && + harnessSupportsFastMode(runtime.harnessId) && + FAST_MODE_MODEL_IDS.includes(modelId), + } + : {}), + }); } else { const harnessId = isHarnessId(ctx.deps.harnessId) ? ctx.deps.harnessId : "pi"; const effective = await resolveRuntimeChoiceDurable(ctx.deps.config!, scopeId("org", configOrgId()), scope, { @@ -383,7 +398,19 @@ export const ADMIN_RESOURCES: readonly AdminResource[] = [ return { error: `model ${modelId} is not supported by ${effective.harnessId}` }; const bad = unserviceable(effective.harnessId); if (bad) return bad; - await ctx.deps.config!.setRuntimeSelectionLatest(scope, { harnessId: effective.harnessId, modelId }); + await ctx.deps.config!.setRuntimeSelectionLatest(scope, { + harnessId: effective.harnessId, + modelId, + ...(effective.effortLevel ? { effortLevel: effective.effortLevel } : {}), + ...(typeof effective.fastMode === "boolean" + ? { + fastMode: + effective.fastMode && + harnessSupportsFastMode(effective.harnessId) && + FAST_MODE_MODEL_IDS.includes(modelId), + } + : {}), + }); } return { ok: true }; }, @@ -402,17 +429,28 @@ export const ADMIN_RESOURCES: readonly AdminResource[] = [ } const harnessId = (ctx.body as { harnessId?: unknown }).harnessId; const modelId = (ctx.body as { modelId?: unknown }).modelId; + const effortLevel = (ctx.body as { effortLevel?: unknown }).effortLevel ?? "auto"; + const fastMode = (ctx.body as { fastMode?: unknown }).fastMode ?? false; if (!isHarnessId(harnessId)) return { error: `runtime requires harnessId (${HARNESS_IDS.join(" | ")})` }; const approved = (await ctx.deps.config!.getApprovedHarnessesDurable()) ?? [ctx.deps.harnessId ?? "pi"]; if (!approved.includes(harnessId)) return { error: `harness ${harnessId} is not approved` }; if (typeof modelId !== "string" || !modelSupportedByHarness(modelId, harnessId)) return { error: `model ${String(modelId)} is not supported by ${harnessId}` }; + const thinkingLevels = thinkingLevelsForHarness(harnessId); + if (typeof effortLevel !== "string" || !thinkingLevels.includes(effortLevel)) + return { error: `runtime requires effortLevel (${thinkingLevels.join(" | ")}) for ${harnessId}` }; + if (typeof fastMode !== "boolean") return { error: "runtime requires fastMode (boolean)" }; const runtimeKeys = ctx.deps.providerKeys ?? ALL_PROVIDERS_AVAILABLE; if (!modelServiceable(modelId, modelProviderAvailabilityFor(harnessId, runtimeKeys))) return { error: `model ${modelId} isn't serviceable on this deployment: its provider key is not configured for the ${harnessId} harness`, }; - await ctx.deps.config!.setRuntimeSelectionLatest(scope, { harnessId, modelId }); + await ctx.deps.config!.setRuntimeSelectionLatest(scope, { + harnessId, + modelId, + effortLevel, + fastMode: fastMode && harnessSupportsFastMode(harnessId) && FAST_MODE_MODEL_IDS.includes(modelId), + }); return { ok: true }; }, }, diff --git a/src/api/routes/admin/scope-config.ts b/src/api/routes/admin/scope-config.ts index 22acc4f7e..a9e8846a0 100644 --- a/src/api/routes/admin/scope-config.ts +++ b/src/api/routes/admin/scope-config.ts @@ -2,6 +2,8 @@ import { parseScopeId } from "../../../types.ts"; import { encodeRef, serviceCredRef } from "../../../acl/resource-ref.ts"; import { computeRetention } from "../../../admin/retention.ts"; import { + FAST_MODE_MODEL_IDS, + harnessSupportsFastMode, HARNESS_IDS, SELECTABLE_BASE_MODELS, defaultModelForHarness, @@ -9,6 +11,7 @@ import { modelServiceable, ALL_PROVIDERS_AVAILABLE, resolveModel, + thinkingLevelsForHarness, } from "../../../model/pi-models.ts"; import { builtInModelCatalog, @@ -270,6 +273,11 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { harnessDefault: deps.harnessId ?? "pi", harnessOptions: HARNESS_IDS.filter((id) => id !== "mock"), modelsByHarness: Object.fromEntries(HARNESS_IDS.map((id) => [id, modelsFor(id)])), + thinkingLevelsByHarness: Object.fromEntries( + HARNESS_IDS.filter((id) => id !== "mock").map((id) => [id, thinkingLevelsForHarness(id)]), + ), + fastModeModelIds: FAST_MODE_MODEL_IDS, + fastModeHarnessIds: HARNESS_IDS.filter(harnessSupportsFastMode), browseModelOptions: SELECTABLE_BASE_MODELS.filter((m) => modelServiceable(m.id, providersFor(deps.harnessId ?? "pi")), ), diff --git a/src/harness/harness-router.ts b/src/harness/harness-router.ts index 280896272..db90d0ed3 100644 --- a/src/harness/harness-router.ts +++ b/src/harness/harness-router.ts @@ -1,5 +1,13 @@ import type { ScopedConfigStore } from "../resolution/config-store.ts"; -import { defaultModelForHarness, isHarnessId, modelSupportedByHarness, type HarnessId } from "../model/pi-models.ts"; +import { + defaultModelForHarness, + FAST_MODE_MODEL_IDS, + harnessSupportsFastMode, + isHarnessId, + modelSupportedByHarness, + thinkingLevelsForHarness, + type HarnessId, +} from "../model/pi-models.ts"; import type { ScopeId } from "../types.ts"; import type { Harness, HarnessTurnInput } from "./harness.ts"; import { NonRetryableTurnError } from "../core/turn-error.ts"; @@ -7,6 +15,26 @@ import { NonRetryableTurnError } from "../core/turn-error.ts"; export interface RuntimeChoice { harnessId: HarnessId; modelId: string; + effortLevel?: string; + fastMode?: boolean; +} + +function normalizeRuntimeChoice(choice: RuntimeChoice): RuntimeChoice { + return { + harnessId: choice.harnessId, + modelId: choice.modelId, + ...(choice.effortLevel && thinkingLevelsForHarness(choice.harnessId).includes(choice.effortLevel) + ? { effortLevel: choice.effortLevel } + : {}), + ...(typeof choice.fastMode === "boolean" + ? { + fastMode: + choice.fastMode && + harnessSupportsFastMode(choice.harnessId) && + FAST_MODE_MODEL_IDS.includes(choice.modelId), + } + : {}), + }; } export function resolveRuntimeChoice( @@ -19,9 +47,14 @@ export function resolveRuntimeChoice( const approved = config.getApprovedHarnesses() ?? [fallback.harnessId]; const orgStored = config.getRuntimeSelection(orgScopeId); const orgLegacy = config.getBaseModel(orgScopeId); - const configuredOrg = + const configuredOrg: RuntimeChoice = orgStored && isHarnessId(orgStored.harnessId) - ? { harnessId: orgStored.harnessId, modelId: orgStored.modelId } + ? { + harnessId: orgStored.harnessId, + modelId: orgStored.modelId, + ...(orgStored.effortLevel ? { effortLevel: orgStored.effortLevel } : {}), + ...(typeof orgStored.fastMode === "boolean" ? { fastMode: orgStored.fastMode } : {}), + } : { harnessId: fallback.harnessId, modelId: orgLegacy ?? fallback.modelId }; const firstApproved = approved.find(isHarnessId) ?? fallback.harnessId; const safeFallback = @@ -35,22 +68,24 @@ export function resolveRuntimeChoice( : safeFallback; const scopedStored = scope === orgScopeId ? null : config.getRuntimeSelection(scope); const scopedLegacy = scope === orgScopeId ? null : config.getBaseModel(scope); - let inherited = org; + let inherited: RuntimeChoice = org; if (scopedStored && isHarnessId(scopedStored.harnessId)) { - inherited = { harnessId: scopedStored.harnessId, modelId: scopedStored.modelId }; + inherited = { + harnessId: scopedStored.harnessId, + modelId: scopedStored.modelId, + ...(scopedStored.effortLevel ? { effortLevel: scopedStored.effortLevel } : {}), + ...(typeof scopedStored.fastMode === "boolean" ? { fastMode: scopedStored.fastMode } : {}), + }; } else if (scopedLegacy) { inherited = { harnessId: fallback.harnessId, modelId: scopedLegacy }; } - const choice = - requested?.harnessId || requested?.modelId - ? { harnessId: requested.harnessId ?? inherited.harnessId, modelId: requested.modelId ?? inherited.modelId } - : inherited; + const choice = requested?.harnessId || requested?.modelId ? { ...inherited, ...requested } : inherited; if (!approved.includes(choice.harnessId) || !modelSupportedByHarness(choice.modelId, choice.harnessId)) { if (requested?.harnessId || requested?.modelId) throw new NonRetryableTurnError(`runtime ${choice.harnessId}/${choice.modelId} is not approved`); - return org; + return normalizeRuntimeChoice(org); } - return choice; + return normalizeRuntimeChoice(choice); } export async function resolveRuntimeChoiceDurable( @@ -102,7 +137,13 @@ export function createHarnessRouter( await adapter.turns.resetSession?.(input.session.id); } lastHarness.set(input.session.id, choice.harnessId); - return adapter.turns.runTurn({ ...input, harness: choice.harnessId, model: choice.modelId }); + return adapter.turns.runTurn({ + ...input, + harness: choice.harnessId, + model: choice.modelId, + thinkingLevel: input.thinkingLevel ?? choice.effortLevel, + fastMode: input.fastMode ?? choice.fastMode, + }); }, async resetSession(sessionId) { lastHarness.delete(sessionId); diff --git a/src/harness/pi-harness.ts b/src/harness/pi-harness.ts index 0c8a96b34..ff74176dd 100644 --- a/src/harness/pi-harness.ts +++ b/src/harness/pi-harness.ts @@ -1192,15 +1192,17 @@ function applyEffortAliases(model: unknown): void { }; } -function applyTurnEffort(session: AgentSession, level?: string): void { +export function applyTurnEffort(session: AgentSession, level?: string): void { if (!level || !TURN_EFFORT_LEVELS.has(level)) return; - if (level === "auto") return; + const effectiveLevel = + level === "auto" && session.state.model ? defaultInteractiveThinkingLevel(session.state.model) : level; + const normalizedLevel = effectiveLevel === "auto" ? "medium" : effectiveLevel; applyEffortAliases(session.state.model); try { - if (LEGACY_THINKING_LEVELS.has(level)) { - session.setThinkingLevel(level as LegacyThinkingLevel); + if (LEGACY_THINKING_LEVELS.has(normalizedLevel)) { + session.setThinkingLevel(normalizedLevel as LegacyThinkingLevel); } else { - session.state.thinkingLevel = level as typeof session.state.thinkingLevel; + session.state.thinkingLevel = normalizedLevel as typeof session.state.thinkingLevel; } } catch (e) { swallow("pi: set thinking level", e); diff --git a/src/model/pi-models.ts b/src/model/pi-models.ts index 7d193eec4..c8d0973e9 100644 --- a/src/model/pi-models.ts +++ b/src/model/pi-models.ts @@ -11,6 +11,17 @@ export const THINKING_LEVELS = ["auto", "low", "medium", "high", "xhigh", "max", export const HARNESS_IDS = ["pi", "opencode", "codex", "claude", "mock"] as const; export type HarnessId = (typeof HARNESS_IDS)[number]; +export function thinkingLevelsForHarness(harnessId: HarnessId): readonly string[] { + if (harnessId === "pi") return THINKING_LEVELS; + if (harnessId === "claude") return THINKING_LEVELS.filter((level) => level !== "ultracode"); + if (harnessId === "codex") return THINKING_LEVELS.filter((level) => level !== "max" && level !== "ultracode"); + return ["auto"]; +} + +export function harnessSupportsFastMode(harnessId: HarnessId): boolean { + return harnessId === "pi" || harnessId === "claude"; +} + export const MODEL_PROVIDERS = ["anthropic", "openai", "openrouter"] as const; export type ModelProvider = (typeof MODEL_PROVIDERS)[number]; diff --git a/test/admin-resources.test.ts b/test/admin-resources.test.ts index c0bb8545d..d121984cb 100644 --- a/test/admin-resources.test.ts +++ b/test/admin-resources.test.ts @@ -529,6 +529,70 @@ test("base-model is a sparse per-scope override: a channel pins its own model, e } }); +test("admin runtime saves reasoning level and fast mode with the default model", async () => { + const srv = start(); + const url = `${srv.base}/v1/admin/scopes/org:default-org/runtime`; + try { + srv.built.config.setApprovedHarnesses(["pi", "opencode", "codex"]); + await srv.built.config.flushScope("org:default-org"); + const saved = await fetch(url, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ harnessId: "pi", modelId: "claude-opus-5", effortLevel: "high", fastMode: true }), + }); + assert.equal(saved.status, 200); + assert.deepEqual(await srv.built.config.getRuntimeSelectionDurable("org:default-org"), { + harnessId: "pi", + modelId: "claude-opus-5", + effortLevel: "high", + fastMode: true, + orgRevision: 1, + revision: 1, + }); + + const changedModel = await fetch(`${srv.base}/v1/admin/scopes/org:default-org/base-model`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ modelId: "claude-fable-5" }), + }); + assert.equal(changedModel.status, 200); + assert.deepEqual(await srv.built.config.getRuntimeSelectionDurable("org:default-org"), { + harnessId: "pi", + modelId: "claude-fable-5", + effortLevel: "high", + fastMode: false, + orgRevision: 2, + revision: 2, + }); + + const unsupported = await fetch(url, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ harnessId: "pi", modelId: "claude-fable-5", effortLevel: "low", fastMode: true }), + }); + assert.equal(unsupported.status, 200); + assert.equal((await srv.built.config.getRuntimeSelectionDurable("org:default-org"))?.fastMode, false); + + const unsupportedHarness = await fetch(url, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ harnessId: "opencode", modelId: "claude-opus-5", effortLevel: "auto", fastMode: true }), + }); + assert.equal(unsupportedHarness.status, 200); + assert.equal((await srv.built.config.getRuntimeSelectionDurable("org:default-org"))?.fastMode, false); + + for (const body of [ + { harnessId: "pi", modelId: "claude-opus-5", effortLevel: "extreme", fastMode: true }, + { harnessId: "codex", modelId: "gpt-5.5", effortLevel: "max", fastMode: false }, + { harnessId: "pi", modelId: "claude-opus-5", effortLevel: "high", fastMode: "yes" }, + ]) { + assert.equal((await fetch(url, { method: "PUT", headers: ADMIN, body: JSON.stringify(body) })).status, 400); + } + } finally { + await srv.close(); + } +}); + test("ambient-policy edits a channel's standing order and bot ledger through the registry", async () => { const srv = start(); try { diff --git a/test/pi-harness-fast-mode.test.ts b/test/pi-harness-fast-mode.test.ts index 0d6d5f458..fdb4db9a1 100644 --- a/test/pi-harness-fast-mode.test.ts +++ b/test/pi-harness-fast-mode.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { + applyTurnEffort, applyFastSpeed, modelSupportsFastMode, wantsFastMode, @@ -70,3 +71,17 @@ test("an explicit opt-in still cannot select fast mode on a model that lacks it" assert.equal(wantsFastMode(true, undefined), false); assert.equal(wantsFastMode(true, ""), false); }); + +test("auto resets a reused Anthropic session to its interactive default", () => { + const session = { + state: { + model: { provider: "anthropic", api: "anthropic-messages" }, + thinkingLevel: "high", + }, + setThinkingLevel(level: string) { + this.state.thinkingLevel = level; + }, + }; + applyTurnEffort(session as never, "auto"); + assert.equal(session.state.thinkingLevel, "low"); +}); diff --git a/test/runtime-selection.test.ts b/test/runtime-selection.test.ts index 81d92c91a..7184e2ec1 100644 --- a/test/runtime-selection.test.ts +++ b/test/runtime-selection.test.ts @@ -51,6 +51,54 @@ test("runtime resolution uses explicit choice, then scope, then org and rejects ); }); +test("runtime resolution carries reasoning and fast-mode defaults into turns", () => { + const config = createMemoryConfigStore("default-org"); + config.setApprovedHarnesses(["pi", "opencode", "codex"]); + config.setRuntimeSelection(ORG, { + harnessId: "pi", + modelId: "claude-opus-5", + effortLevel: "high", + fastMode: true, + }); + assert.deepEqual(resolveRuntimeChoice(config, ORG, PERSONAL, { harnessId: "pi", modelId: "claude-fable-5" }), { + harnessId: "pi", + modelId: "claude-opus-5", + effortLevel: "high", + fastMode: true, + }); + assert.deepEqual( + resolveRuntimeChoice( + config, + ORG, + PERSONAL, + { harnessId: "pi", modelId: "claude-fable-5" }, + { + harnessId: "codex", + modelId: "gpt-5.5", + }, + ), + { + harnessId: "codex", + modelId: "gpt-5.5", + effortLevel: "high", + fastMode: false, + }, + ); + assert.deepEqual( + resolveRuntimeChoice( + config, + ORG, + PERSONAL, + { harnessId: "pi", modelId: "claude-fable-5" }, + { + harnessId: "opencode", + modelId: "claude-opus-5", + }, + ), + { harnessId: "opencode", modelId: "claude-opus-5", fastMode: false }, + ); +}); + test("runtime resolution falls back to the first approved harness when deployment defaults are not approved", () => { const config = createMemoryConfigStore("default-org"); config.setApprovedHarnesses(["codex"]);