diff --git a/server/contracts.ts b/server/contracts.ts index c42b2cd0b..394d92d12 100644 --- a/server/contracts.ts +++ b/server/contracts.ts @@ -28,12 +28,24 @@ export class ProviderError extends Error { } } +/** Reasoning-effort levels, ascending. A union of everything any engine + * accepts; each driver declares the subset its CLI will take. */ +export const EFFORT_LEVELS = ["none", "low", "medium", "high", "xhigh", "max"] as const; +export type EffortLevel = (typeof EFFORT_LEVELS)[number]; + +/** Narrow untrusted API/config input before it becomes a model selection. */ +export function isEffortLevel(value: unknown): value is EffortLevel { + return typeof value === "string" && (EFFORT_LEVELS as readonly string[]).includes(value); +} + // ── model selection ──────────────────────────────────────────────────── // "Which model" is a data value carried on the request, never a service // binding (upstream ModelSelectionWire). instanceId is the routing key. export interface ModelSelection { instanceId: InstanceId; model: string; + /** Optional: no effort means no flag, and the CLI keeps its own default. */ + effort?: EffortLevel; } // ── instance configuration envelope ──────────────────────────────────── @@ -110,6 +122,7 @@ export interface SendTurnInput { threadId: ThreadId; text: string; model?: string; + effort?: EffortLevel; resumeCursor?: unknown; /** Prior turns for transcript-replay providers (API-backed drivers). */ transcript?: Array<{ role: "user" | "assistant"; text: string }>; @@ -154,6 +167,10 @@ export interface ProviderAdapter { * connected apps). Same rule again: a key in the config says the user * HAS those connections, not that this driver can reach them. */ composioMcp?: boolean; + /** Effort levels this driver can pass to its CLI, ascending. Absent = + * the driver cannot set effort, so the app never offers the control — + * same rule as computerMcp: never show a knob the driver cannot turn. */ + effortLevels?: readonly EffortLevel[]; }; sendTurn(input: SendTurnInput): Promise; interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise; diff --git a/server/drivers/acp/acp.test.ts b/server/drivers/acp/acp.test.ts index 29525a7d9..6f4c69723 100644 --- a/server/drivers/acp/acp.test.ts +++ b/server/drivers/acp/acp.test.ts @@ -455,6 +455,37 @@ describe("ACP turns (fake CLI)", () => { expect(JSON.parse(readFileSync(dump, "utf8")).env.TEST_POLICY).toBe("auto"); }); + + it("declares effort levels for Grok only", async () => { + await create(GrokAgentDriver); + expect(instance.adapter.capabilities.effortLevels).toEqual(["low", "medium", "high"]); + + await create(GeminiAgentDriver); + expect(instance.adapter.capabilities.effortLevels).toBeUndefined(); + + await create(KimiAgentDriver); + expect(instance.adapter.capabilities.effortLevels).toBeUndefined(); + }); + + it("passes effort to Grok, and omits the flag when unset", async () => { + const withEffort = join(scratch, "grok-effort.json"); + await create(GrokAgentDriver); + process.env.FAKE_ACP_DUMP = withEffort; + await instance.adapter.sendTurn({ threadId: "t-effort", text: "hi", effort: "high" }); + await recorder.until((e) => e.type === "turn.completed"); + + const seen = JSON.parse(readFileSync(withEffort, "utf8")); + expect(seen.argv).toContain("--reasoning-effort"); + expect(seen.argv[seen.argv.indexOf("--reasoning-effort") + 1]).toBe("high"); + + const without = join(scratch, "grok-no-effort.json"); + await create(GrokAgentDriver); + process.env.FAKE_ACP_DUMP = without; + await instance.adapter.sendTurn({ threadId: "t-no-effort", text: "hi" }); + await recorder.until((e) => e.type === "turn.completed"); + + expect(JSON.parse(readFileSync(without, "utf8")).argv).not.toContain("--reasoning-effort"); + }); }); describe("ACP snapshot", () => { diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index cd66129ad..c5008e3c2 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -22,6 +22,7 @@ import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../../proc import type { DriverCreateInput, + EffortLevel, EngineInstall, ProviderDriver, ProviderInstance, @@ -56,6 +57,11 @@ export interface AcpSupport { driverKind: string; displayName: string; models: { default: string; options: Array<{ id: string; label: string }> }; + /** Effort levels this harness's CLI accepts, ascending. Omit when it has + * no reasoning-effort control. Static for the same reason `models` is: + * describe() runs before any session exists, so there is no _meta to read + * — eventually both should come from initialize's _meta.modelState. */ + effortLevels?: readonly EffortLevel[]; /** Default CLI binary name if the instance config doesn't override it. */ defaultCli: string; /** Optional live model catalog. A failed lookup keeps the last usable catalog. */ @@ -639,7 +645,12 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver snapshot, adapter: { provider: DRIVER_KIND, - capabilities: { sessionModelSwitch: "unsupported", agentsMcp: true, computerMcp: true }, + capabilities: { + sessionModelSwitch: "unsupported", + agentsMcp: true, + computerMcp: true, + effortLevels: support.effortLevels, + }, sendTurn, interruptTurn: async (threadId) => active.get(threadId)?.interrupt(), respondToRequest: async (threadId, requestId, decision) => { diff --git a/server/drivers/acp/grok.ts b/server/drivers/acp/grok.ts index 9185ca656..a1058274f 100644 --- a/server/drivers/acp/grok.ts +++ b/server/drivers/acp/grok.ts @@ -23,6 +23,10 @@ const support: AcpSupport = { { id: "grok-4.5", label: "Grok 4.5" }, ], }, + // Grok's accepted levels vary by model and the CLI validates lazily — a + // rejected level only logs and falls back. Offer the intersection shared + // by every model in this driver's picker; notably, grok-4.5 rejects xhigh. + effortLevels: ["low", "medium", "high"], defaultCli: "grok", nativeSource: "grok.acp", loginNote: "Grok CLI is not signed in — run `grok login` in a terminal", @@ -46,6 +50,9 @@ const support: AcpSupport = { "--permission-mode", config.fullAuto ? "bypassPermissions" : "default", ...(turn.model ? ["-m", turn.model] : []), + // long form on purpose: `--effort` is documented as an alias, and an + // alias is the part a CLI is free to rename + ...(turn.effort ? ["--reasoning-effort", turn.effort] : []), "agent", "stdio", ], diff --git a/server/drivers/claude.test.ts b/server/drivers/claude.test.ts index 48a1b347f..9b9485752 100644 --- a/server/drivers/claude.test.ts +++ b/server/drivers/claude.test.ts @@ -350,6 +350,39 @@ describe("ClaudeDriver turns (fake CLI)", () => { await instance.adapter.interruptTurn("t-perm-2"); await recorder.until((e) => e.type === "turn.completed"); }); + + it("passes effort to the CLI, and omits the flag when unset", async () => { + await create(); + const dump = join(scratch, "effort.json"); + process.env.FAKE_CLAUDE_DUMP = dump; + + await instance.adapter.sendTurn({ threadId: "t-effort", text: "hi", effort: "xhigh" }); + await recorder.until((e) => e.type === "turn.completed"); + + const seen = JSON.parse(readFileSync(dump, "utf8")); + expect(seen.argv).toContain("--effort"); + expect(seen.argv[seen.argv.indexOf("--effort") + 1]).toBe("xhigh"); + expect(seen.argv.filter((a: string) => a === "--effort")).toHaveLength(1); + }); + + it("adds no effort flag when the turn has none", async () => { + await create(); + const dump = join(scratch, "no-effort.json"); + process.env.FAKE_CLAUDE_DUMP = dump; + + await instance.adapter.sendTurn({ threadId: "t-no-effort", text: "hi" }); + await recorder.until((e) => e.type === "turn.completed"); + + const seen = JSON.parse(readFileSync(dump, "utf8")); + expect(seen.argv).not.toContain("--effort"); + }); + + it("declares the effort levels the CLI accepts", async () => { + await create(); + expect(instance.adapter.capabilities.effortLevels).toEqual([ + "low", "medium", "high", "xhigh", "max", + ]); + }); }); // Auth state must come from the CLI, not from probing its credential store: diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index 515b82eb6..39dc3f3f7 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -298,6 +298,7 @@ export const ClaudeDriver: ProviderDriver = { if (sessionId) args.push("--resume", sessionId); else args.push("--session-id", newSessionId!); if (turn.model) args.push("--model", turn.model); + if (turn.effort) args.push("--effort", turn.effort); if (turn.system) args.push("--append-system-prompt", turn.system); // integrations → MCP servers; pre-allow their tools (a headless @@ -560,7 +561,13 @@ export const ClaudeDriver: ProviderDriver = { snapshot, adapter: { provider: DRIVER_KIND, - capabilities: { sessionModelSwitch: "in-session", agentsMcp: true, computerMcp: true, composioMcp: true }, + capabilities: { + sessionModelSwitch: "in-session", + agentsMcp: true, + computerMcp: true, + composioMcp: true, + effortLevels: ["low", "medium", "high", "xhigh", "max"], + }, sendTurn, interruptTurn: async (threadId) => active.get(threadId)?.stop(), respondToRequest: async (threadId, requestId, decision) => { diff --git a/server/drivers/codex.test.ts b/server/drivers/codex.test.ts index 036f8be1b..1672b34ff 100644 --- a/server/drivers/codex.test.ts +++ b/server/drivers/codex.test.ts @@ -197,4 +197,37 @@ describe("CodexDriver turns (fake app-server)", () => { expect(done).toMatchObject({ ok: false }); expect(await instance.snapshot()).toMatchObject({ state: "unavailable" }); }); + + it("declares the effort levels the app-server accepts", async () => { + await create(); + expect(instance.adapter.capabilities.effortLevels).toEqual([ + "low", "medium", "high", "xhigh", "max", + ]); + }); + + it("sends effort on turn/start, and omits the key when unset", async () => { + await create(); + const dump = join(scratch, "effort.json"); + process.env.FAKE_CODEX_DUMP = dump; + + await instance.adapter.sendTurn({ threadId: "t-effort", text: "hi", effort: "xhigh" }); + await recorder.until((e) => e.type === "turn.completed"); + + const seen = JSON.parse(readFileSync(dump, "utf8")); + const turnStart = seen.calls.find((c: any) => c.method === "turn/start"); + expect(turnStart.params.effort).toBe("xhigh"); + }); + + it("sends no effort key when the turn has none", async () => { + await create(); + const dump = join(scratch, "no-effort.json"); + process.env.FAKE_CODEX_DUMP = dump; + + await instance.adapter.sendTurn({ threadId: "t-no-effort", text: "hi" }); + await recorder.until((e) => e.type === "turn.completed"); + + const seen = JSON.parse(readFileSync(dump, "utf8")); + const turnStart = seen.calls.find((c: any) => c.method === "turn/start"); + expect(turnStart.params).not.toHaveProperty("effort"); + }); }); diff --git a/server/drivers/codex.ts b/server/drivers/codex.ts index 74ce2cbbd..781ced1ed 100644 --- a/server/drivers/codex.ts +++ b/server/drivers/codex.ts @@ -389,6 +389,16 @@ export const CodexDriver: ProviderDriver = { await request("turn/start", { threadId: codexThreadId, input: [{ type: "text", text: turn.system ? `${turn.system}\n\n${turn.text}` : turn.text }], + // Spread, not `effort: turn.effort ?? null`. Probed against + // codex-cli 0.146.0: null is indistinguishable from an absent key + // — both leave the thread's current effort alone, emitting no + // thread/settings/updated, and thread/resume reads the old value + // back. The app-server offers no way to clear a level either: + // "" is rejected outright and thread/start takes no effort at + // all. So a thread keeps the last level it was sent until it is + // sent another, and choosing Default lands on the bot's next new + // thread rather than the current one. + ...(turn.effort ? { effort: turn.effort } : {}), }); } catch (e) { if (!state.settled) { @@ -420,7 +430,10 @@ export const CodexDriver: ProviderDriver = { snapshot, adapter: { provider: DRIVER_KIND, - capabilities: { sessionModelSwitch: "unsupported" }, + capabilities: { + sessionModelSwitch: "unsupported", + effortLevels: ["low", "medium", "high", "xhigh", "max"], + }, sendTurn, interruptTurn: async (threadId) => active.get(threadId)?.stop(), respondToRequest: async (threadId, requestId, decision) => { diff --git a/server/harness/registry.test.ts b/server/harness/registry.test.ts index f4004e420..c76eb7ac3 100644 --- a/server/harness/registry.test.ts +++ b/server/harness/registry.test.ts @@ -75,6 +75,24 @@ describe("ProviderRegistry", () => { expect(described.snapshot).toMatchObject({ state: "unavailable", reason: "provider probe exploded" }); }); + it("forwards a live instance's declared effort levels in describe()", async () => { + const fake = makeFakeDriver({ effortLevels: ["low", "high"] }); + const registry = new ProviderRegistry([fake.driver]); + await registry.load({ a: { driver: "fake" } }); + + const [described] = await registry.describe(); + expect(described.capabilities.effortLevels).toEqual(["low", "high"]); + }); + + it("omits effortLevels from describe() when the driver declares none", async () => { + const fake = makeFakeDriver(); + const registry = new ProviderRegistry([fake.driver]); + await registry.load({ a: { driver: "fake" } }); + + const [described] = await registry.describe(); + expect(described.capabilities.effortLevels).toBeUndefined(); + }); + it("disposeAll disposes every live instance and empties the registry", async () => { const fake = makeFakeDriver(); const registry = new ProviderRegistry([fake.driver]); diff --git a/server/harness/registry.ts b/server/harness/registry.ts index df567fa5d..c2377fb82 100644 --- a/server/harness/registry.ts +++ b/server/harness/registry.ts @@ -118,6 +118,7 @@ export class ProviderRegistry { capabilities: { computerMcp: inst.adapter.capabilities.computerMcp === true, agentsMcp: inst.adapter.capabilities.agentsMcp === true, + effortLevels: inst.adapter.capabilities.effortLevels, }, install: this.driversByKind.get(inst.driverKind)?.install, }; diff --git a/server/index.test.ts b/server/index.test.ts index b3db6491c..1d8aecd64 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -291,6 +291,78 @@ describe("harness HTTP API", () => { } }); + it("keeps the rest of a duplicate's fields when the source engine is offline", async () => { + // duplicateBot POSTs a blank bot, then PATCHes the source's whole + // modelSelection in one body beside its name, title and description. + // "ghost" is an unknown driver, so the registry resolves nothing and the + // level cannot be verified — which must not cost the copy everything + // else in the request. + const copy = (await api("POST", "/api/bots")).body.bot; + + const patched = await api("PATCH", `/api/bots/${copy.id}`, { + name: "Reviewer copy", + title: "Reviewer", + description: "reads diffs", + modelSelection: { instanceId: "ghost", model: "ghost-1", effort: "xhigh" }, + }); + + expect(patched.status).toBe(200); + expect(patched.body.bot).toMatchObject({ + name: "Reviewer copy", + title: "Reviewer", + description: "reads diffs", + modelSelection: { instanceId: "ghost", model: "ghost-1", effort: "xhigh" }, + }); + }); + + it("rejects an unknown effort value even while the engine is offline", async () => { + const bot = (await api("POST", "/api/bots")).body.bot; + const patched = await api("PATCH", `/api/bots/${bot.id}`, { + modelSelection: { instanceId: "ghost", model: "ghost-1", effort: "turbo" }, + }); + + expect(patched.status).toBe(400); + expect(patched.body.error).toContain("not recognized"); + }); + + it("leaves a bot with no effort level untouched", async () => { + const bot = (await api("POST", "/api/bots")).body.bot; + expect(bot.modelSelection.effort).toBeUndefined(); + + const renamed = await api("PATCH", `/api/bots/${bot.id}`, { name: "Plain" }); + expect(renamed.status).toBe(200); + expect(renamed.body.bot.modelSelection.effort).toBeUndefined(); + }); + + // This fixture pins a single unknown driver, so no instance here ever + // resolves: these cover the gate's pass-through and the store's replace + // semantics, NOT the comparison against a live engine's declared list. + // That branch has no coverage at this layer, and manufacturing a live + // instance in this fixture would cost it its no-probe determinism. + it("round-trips an effort level and clears it when the key is dropped", async () => { + const bot = (await api("POST", "/api/bots")).body.bot; + const selection = { instanceId: "ghost", model: "ghost-1" }; + + const set = await api("PATCH", `/api/bots/${bot.id}`, { + modelSelection: { ...selection, effort: "high" }, + }); + expect(set.status).toBe(200); + expect(set.body.bot.modelSelection.effort).toBe("high"); + + const reread = (await api("GET", "/api/bots")).body.bots.find((b: { id: string }) => b.id === bot.id); + expect(reread.modelSelection.effort).toBe("high"); + + // The panel's "Default" button spreads the selection with effort: + // undefined, and JSON.stringify drops the key — so clearing reaches the + // server as a modelSelection carrying no effort at all. + const cleared = await api("PATCH", `/api/bots/${bot.id}`, { modelSelection: selection }); + expect(cleared.status).toBe(200); + + const after = (await api("GET", "/api/bots")).body.bots.find((b: { id: string }) => b.id === bot.id); + expect(after.modelSelection).toEqual(selection); + expect(after.modelSelection.effort).toBeUndefined(); + }); + it("persists an answered onboarding card", async () => { const { body } = await api("GET", "/api/bots"); const bot = body.bots[0]; diff --git a/server/index.ts b/server/index.ts index 6848b7a57..c7a6cb769 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,7 +23,7 @@ import { import { ensureDirs, instanceConfigs, loadConfig, saveConfig, EVENTS_DIR, NATIVE_DIR } from "./config.ts"; import { resetPathCache } from "./env-path.ts"; import { buildNotification, type Notification } from "./notify.ts"; -import type { RuntimeEvent } from "./contracts.ts"; +import { isEffortLevel, type RuntimeEvent } from "./contracts.ts"; import { BUILT_IN_DRIVERS } from "./drivers/builtIn.ts"; import { getOrCreateChannel, mirrorExchange, mirrorReply, type CommsBus } from "./comms-visibility.ts"; @@ -691,6 +691,17 @@ async function startTurn( } const instanceId = instance.instanceId; const model = opts?.runOn === "cloud" ? instance.models.default : bot.modelSelection.model; + // a cloud routine borrows the instance default model, so it borrows no + // per-bot effort either + const effort = opts?.runOn === "cloud" ? undefined : bot.modelSelection.effort; + // A selection can be persisted while its engine is offline. Re-check when + // the engine returns so an old or unsupported value never reaches a CLI. + if (effort && !instance.adapter.capabilities.effortLevels?.includes(effort)) { + throw Object.assign( + new Error(`effort "${effort}" is not offered by this bot's engine — choose another level in settings`), + { status: 409 }, + ); + } // an edit hands us its already-branched user message; a plain send appends let userMessage = opts?.userMessage; @@ -865,6 +876,7 @@ async function startTurn( threadId, text: turnText, model, + effort, // a rewound thread never resumes the abandoned branch's session // the active task's own session — another task's cursor would // resume the wrong conversation and defeat the context bubble @@ -1779,6 +1791,35 @@ const server = createServer(async (req, res) => { m = path.match(/^\/api\/bots\/([\w-]+)$/); if (m && method === "PATCH") { const body = await readBody(req); + const existing = store.bot(m[1]); + // Neither Codex (free-form string field) nor Grok (lazy, logs-only) + // rejects an unknown effort level at their own boundary — this is the + // only real gate, so it stays. But it fires only when the target + // instance actually resolves. An instance that isn't there declares no + // levels, and rejecting against that empty list would 400 the *whole* + // request: this is the app's general-purpose bot endpoint, and + // duplicateBot re-sends the source bot's entire modelSelection beside + // its name, title and description, so a source engine that happens to + // be offline would cost the copy all of them. Letting it through is + // safe — startTurn refuses to run a turn on an unavailable instance + // anyway, so an unverifiable level never reaches a CLI. + const nextSelection = (body as Record).modelSelection as + | { instanceId?: string; effort?: string } + | undefined; + if (nextSelection?.effort !== undefined) { + if (!isEffortLevel(nextSelection.effort)) { + return json(res, 400, { error: `effort "${String(nextSelection.effort)}" is not recognized` }); + } + const target = registry.get(nextSelection.instanceId ?? existing?.modelSelection.instanceId ?? ""); + // typed as strings, not levels: this is the boundary that decides + // whether the value *is* a level, so it must not assert that it is + const allowed: readonly string[] = target?.adapter.capabilities.effortLevels ?? []; + if (target && !allowed.includes(nextSelection.effort)) { + return json(res, 400, { + error: `effort "${nextSelection.effort}" is not offered by this bot's engine`, + }); + } + } const patch: Record = {}; for (const key of ["name", "title", "description", "notifications", "modelSelection", "unread", "computer", "color", "mascotExpression", "pinned", "hidden", "speakReplies", "voice"] as const) { if (body[key] !== undefined) patch[key] = body[key]; @@ -1792,7 +1833,6 @@ const server = createServer(async (req, res) => { if (body.chiefOfStaff !== undefined && typeof body.chiefOfStaff !== "boolean") { return json(res, 400, { error: "chiefOfStaff must be true or false" }); } - const existing = store.bot(m[1]); if (body.hidden === true && existing?.chiefOfStaff && body.chiefOfStaff !== false) { return json(res, 400, { error: "choose another Chief of Staff before hiding this bot" }); } diff --git a/server/store.test.ts b/server/store.test.ts index 4a9d0d813..a3a994d77 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -101,6 +101,17 @@ describe("Store", () => { ); }); + it("persists a bot's effort level across a restart, defaulting to unset", () => { + const store = new Store(selection); + const bot = store.createBot(); + expect(bot.modelSelection.effort).toBeUndefined(); + + store.patchBot(bot.id, { modelSelection: { ...bot.modelSelection, effort: "high" } }); + + const reloaded = new Store(selection); + expect(reloaded.bot(bot.id)?.modelSelection.effort).toBe("high"); + }); + it("keeps exactly one persisted Chief of Staff and supports handoff", () => { const store = new Store(selection); const first = store.createBot(); diff --git a/server/testing/fake-driver.ts b/server/testing/fake-driver.ts index 13f28f722..f64a63717 100644 --- a/server/testing/fake-driver.ts +++ b/server/testing/fake-driver.ts @@ -3,6 +3,7 @@ // canonical events as if the provider produced them. import type { DriverCreateInput, + EffortLevel, ProviderDriver, ProviderInstance, ProviderSnapshot, @@ -16,6 +17,8 @@ export interface FakeDriverOptions { failCreate?: string; /** snapshot() rejects with this message (describe-downgrade path). */ failSnapshot?: string; + /** effort levels this fake driver declares, forwarded onto capabilities. */ + effortLevels?: readonly EffortLevel[]; } export interface FakeDriverHandle { @@ -62,7 +65,7 @@ export function makeFakeDriver(opts: FakeDriverOptions = {}): FakeDriverHandle { }, adapter: { provider: kind, - capabilities: { sessionModelSwitch: "unsupported" }, + capabilities: { sessionModelSwitch: "unsupported", effortLevels: opts.effortLevels }, sendTurn: async () => ({ turnId: "fake-turn" }), interruptTurn: async () => {}, respondToRequest: async () => {}, diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index 3e508c397..0632a0d26 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -46,7 +46,21 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string } }, [open]); const pick = (instance: InstanceInfo, model: string) => { - dispatch({ type: "setModel", botId: bot.id, selection: { instanceId: instance.instanceId, model } }); + // setModel replaces the whole selection, so a configured effort has to be + // carried across deliberately. Same engine, different model: keep it — + // silently resetting the level the user chose is not what "pick a model" + // means. Different engine: drop it, since effort vocabularies are + // per-driver and the old level may be one the new engine never declared. + const sameInstance = instance.instanceId === selection.instanceId; + dispatch({ + type: "setModel", + botId: bot.id, + selection: { + instanceId: instance.instanceId, + model, + ...(sameInstance && selection.effort ? { effort: selection.effort } : {}), + }, + }); setOpen(false); }; diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 1d5161c1c..3c3c68120 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -50,6 +50,7 @@ export function SettingsPanel({ bot }: { bot: Bot }) { | "voice" | "chiefOfStaff" | "approvePeerComms" + | "modelSelection" > >, ) => dispatch({ type: "updateBot", botId: bot.id, patch: p }); @@ -275,6 +276,39 @@ export function SettingsPanel({ bot }: { bot: Bot }) { + {!!engine?.capabilities?.effortLevels?.length && ( +
+
Effort
+ {/* Says what the app does, not what the engine ends up at: + Codex applies a level to the whole thread and has no way to + take one back, so "currently: engine default" was a promise + we could not keep for a thread that had already been sent + one. Sending nothing is true on every engine. */} +
+ How hard this bot thinks{bot.modelSelection.effort ? "" : " (Default: no level is sent)"} +
+
+ {([undefined, ...engine.capabilities.effortLevels] as const).map((level, i) => ( + + ))} +
+
+ )} +
Computer
diff --git a/src/state/store.tsx b/src/state/store.tsx index 70644c44c..e1db7705d 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -13,6 +13,7 @@ import { useState, type ReactNode, } from "react"; +import type { EffortLevel } from "../../server/contracts.ts"; import type { MausColor, MausMotion } from "@/lib/mascot"; import type { Routine, RoutineInput, RoutineRun } from "@/lib/routines"; import type { WebhookAttempt, WebhookIngressStatus, WebhookTrigger } from "@/lib/webhooks"; @@ -87,6 +88,7 @@ export interface Group { export interface ModelSelection { instanceId: string; model: string; + effort?: EffortLevel; } /** One of a bot's separate contexts: its own thread, transcript and @@ -196,7 +198,7 @@ export interface InstanceInfo { version?: string | null; }; models: { default: string; options: Array<{ id: string; label: string }> }; - capabilities?: { computerMcp?: boolean; agentsMcp?: boolean }; + capabilities?: { computerMcp?: boolean; agentsMcp?: boolean; effortLevels?: readonly EffortLevel[] }; install?: EngineInstall; } @@ -325,6 +327,7 @@ type Action = | "hidden" | "chiefOfStaff" | "approvePeerComms" + | "modelSelection" > >; };