From 0c832c5277d7ef28d4b07feda2e890921e7f4bc0 Mon Sep 17 00:00:00 2001 From: BaconDroid <11067111+BaconDroid@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:05:33 -0400 Subject: [PATCH 1/6] fix: keep the original baseline for temporary reasoning effort Repeated set_reasoning_effort calls in one turn could reset to an intermediate level, or cancel the reset entirely when the same level was set twice, because the baseline was re-read after the override. Keep the first reset target, read session history once per call with a bounded query, cache provider variants, merge guidance into an existing system prompt, and replace Zod config parsing with a small validator. --- .changeset/quiet-resets.md | 5 + README.md | 2 + package.json | 3 - pnpm-lock.yaml | 8 - src/config.ts | 47 +++- src/index.test.ts | 548 ++++++++++++++----------------------- src/index.ts | 391 +++++++++++--------------- 7 files changed, 409 insertions(+), 595 deletions(-) create mode 100644 .changeset/quiet-resets.md diff --git a/.changeset/quiet-resets.md b/.changeset/quiet-resets.md new file mode 100644 index 0000000..6175dfd --- /dev/null +++ b/.changeset/quiet-resets.md @@ -0,0 +1,5 @@ +--- +"opencode-adaptive-thinking": patch +--- + +Reset a temporary reasoning effort to the level in effect before the first override, so repeated changes no longer reset to an intermediate level or skip the reset. Read session history once per tool call with a bounded query, cache provider variants, merge guidance into an existing first system prompt, and remove the runtime Zod dependency. diff --git a/README.md b/README.md index c4c1b76..c8c1191 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ Example persisted change: The system prompt lists the valid levels for the current session. Cached session state is only used when it is still valid for the current model, so switching models will not advertise or reuse an invalid effort level. +Adaptive-thinking guidance is merged into the first existing system prompt instead of a separate system message, so strict chat templates that allow only one system message keep working. + ## Troubleshooting If the tool returns `no valid reasoning effort levels are available for this session`, check that the active model exposes variants in OpenCode. Some providers or models may not support reasoning-effort levels. diff --git a/package.json b/package.json index 25c2132..c5e347e 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,6 @@ "verify": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build", "version-packages": "changeset version" }, - "dependencies": { - "zod": "^4.4.3" - }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54c6f2c..8698b75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@opencode-ai/sdk': specifier: '>=1' version: 1.17.13 - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@changesets/changelog-github': specifier: ^0.7.0 @@ -1318,9 +1315,6 @@ packages: zod@4.1.8: resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - snapshots: '@ai-sdk/provider@3.0.8': @@ -2379,5 +2373,3 @@ snapshots: yaml@2.9.0: {} zod@4.1.8: {} - - zod@4.4.3: {} diff --git a/src/config.ts b/src/config.ts index bf83459..8213a1f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,12 +1,18 @@ -import * as z from "zod/v4"; - export const defaultSystemPrompt = "You MUST manage reasoning effort actively. " + "Lower it before trivial or routine turns; raise it for ambiguity, debugging, risky changes, or multi-step synthesis. " + "Reassess at turn start, after meaningful new evidence, and when the task shifts. " + "NEVER leave the current level unchanged by inertia, and NEVER reply to a trivial turn before considering a downshift."; -export const configDefaults = { +export type Config = { + enabled: boolean; + quiet: boolean; + toolName: string; + toolDescription: string; + systemPrompt: string; +}; + +const defaults: Config = { enabled: true, quiet: false, toolName: "set_reasoning_effort", @@ -14,13 +20,28 @@ export const configDefaults = { systemPrompt: defaultSystemPrompt, }; -export const ConfigSchema = z - .object({ - enabled: z.boolean().default(configDefaults.enabled), - quiet: z.boolean().default(configDefaults.quiet), - toolName: z.string().default(configDefaults.toolName), - toolDescription: z.string().default(configDefaults.toolDescription), - systemPrompt: z.string().default(configDefaults.systemPrompt), - }) - .optional() - .default(configDefaults); +export const parseConfig = (options: unknown): { config: Config } | { error: string } => { + if (options === undefined) return { config: { ...defaults } }; + if (typeof options !== "object" || options === null || Array.isArray(options)) { + return { error: "options must be an object" }; + } + + const input = options as Record; + const config = { ...defaults }; + + for (const key of ["enabled", "quiet"] as const) { + const value = input[key]; + if (value === undefined) continue; + if (typeof value !== "boolean") return { error: `${key} must be a boolean` }; + config[key] = value; + } + + for (const key of ["toolName", "toolDescription", "systemPrompt"] as const) { + const value = input[key]; + if (value === undefined) continue; + if (typeof value !== "string") return { error: `${key} must be a string` }; + config[key] = value; + } + + return { config }; +}; diff --git a/src/index.test.ts b/src/index.test.ts index 991f46a..49d479e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,418 +1,282 @@ import { describe, expect, test, vi } from "vitest"; import { AdaptiveThinkingPlugin } from "./index"; -const variants = { - none: {}, - low: {}, - medium: {}, - high: {}, - xhigh: {}, -}; - -const createClient = (sessionID: string, messages: Array = []) => { - const promptAsync = vi.fn(async () => ({ error: undefined })); - - return { - client: { - session: { - messages: vi.fn(async () => ({ data: messages, error: undefined })), - promptAsync, - }, - provider: { - list: vi.fn(async () => ({ - data: { - all: [ - { - id: "provider", - models: { - model: { variants }, - }, - }, - ], - }, - error: undefined, - })), - }, - app: { - agents: vi.fn(async () => ({ data: [], error: undefined })), - log: vi.fn(), - }, - tui: { - showToast: vi.fn(), - }, - }, - toolContext: { - sessionID, - messageID: "message", - agent: "agent", - directory: "/tmp", - worktree: "/tmp", - abort: new AbortController().signal, - metadata: vi.fn(), - ask: vi.fn(), - }, - }; -}; - -const createMessage = (variant = "medium") => ({ - info: { - id: "message", - role: "user", - agent: "agent", - model: { - providerID: "provider", - modelID: "model", - variant, - }, - }, - parts: [], -}); +const variants = { none: {}, low: {}, medium: {}, high: {}, xhigh: {} }; -const createAgentMessage = (agent: string) => ({ +const message = (variant?: string, agent = "agent") => ({ info: { - id: `message-${agent}`, role: "user", agent, + model: { providerID: "provider", modelID: "model", ...(variant ? { variant } : {}) }, }, parts: [], }); -const setReasoningEffort = async ( - plugin: Awaited>, - args: { level: string; persist: boolean }, - context: unknown, - toolName = "set_reasoning_effort", -) => plugin.tool![toolName]!.execute(args, context as never); +const createClient = ( + messages: Array = [], + modelVariants: Record = variants, + agents: Record = {}, +) => { + const prompts: Array = []; + const client = { + session: { + messages: vi.fn(async () => ({ data: messages, error: undefined })), + promptAsync: vi.fn(async ({ body }: any) => { + prompts.push(body); + messages.push(message(body.variant)); + return { error: undefined }; + }), + }, + provider: { + list: vi.fn(async () => ({ + data: { + all: [{ id: "provider", models: { model: { variants: modelVariants } } }], + }, + error: undefined, + })), + }, + app: { + agents: vi.fn(async () => ({ + data: Object.entries(agents).map(([name, variant]) => ({ name, variant })), + error: undefined, + })), + log: vi.fn(), + }, + tui: { showToast: vi.fn() }, + }; + return { client, prompts }; +}; -const touchTemporarySession = async (sessionID: string) => { - const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); - const plugin = await AdaptiveThinkingPlugin({ client } as never); +const ctx = (sessionID: string, agent = "agent") => ({ sessionID, agent }); - await setReasoningEffort(plugin, { level: "low", persist: false }, toolContext); +const set = (plugin: any, level: string, persist: boolean, sessionID: string) => + plugin.tool.set_reasoning_effort.execute({ level, persist }, ctx(sessionID)); - return { client, plugin }; -}; +const idle = (plugin: any, sessionID: string) => + plugin.event({ event: { type: "session.idle", properties: { sessionID } } }); + +const transform = ( + plugin: any, + sessionID: string, + system: Array, + model: any = { variants }, +) => plugin["experimental.chat.system.transform"]({ sessionID, model }, { system }); describe("AdaptiveThinkingPlugin", () => { - test("uses defaults when no options are provided", async () => { - const sessionID = "default-options"; - const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); - const plugin = await AdaptiveThinkingPlugin({ client } as never); - const system: string[] = []; - - await setReasoningEffort(plugin, { level: "high", persist: true }, toolContext); - await plugin["experimental.chat.system.transform"]!( - { - sessionID, - model: { variants }, - } as never, - { system }, - ); + test("applies defaults, persists a level and preserves agent and model", async () => { + const { client, prompts } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); - expect(plugin.tool?.set_reasoning_effort?.description).toBe("Set your reasoning effort"); - expect(client.session.promptAsync).toHaveBeenCalledWith( - expect.objectContaining({ body: expect.objectContaining({ variant: "high" }) }), - ); + expect(plugin.tool.set_reasoning_effort.description).toBe("Set your reasoning effort"); + expect(await set(plugin, "high", true, "defaults")).toBe("Reasoning effort set to high"); + expect(prompts[0]).toMatchObject({ + agent: "agent", + variant: "high", + model: { providerID: "provider", modelID: "model" }, + }); + + const system: Array = []; + await transform(plugin, "defaults", system); expect(system[0]).toContain("You MUST manage reasoning effort actively"); + expect(system[0]).toContain("Current reasoning effort level: high."); + expect(system[0]).toContain( + "Valid reasoning effort levels for this session: none, low, medium, high, xhigh.", + ); expect(system[0]).toContain("set_reasoning_effort"); }); - test("preserves the calling agent and model when setting reasoning effort", async () => { - const sessionID = "preserve-agent-model"; - const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); - toolContext.agent = "custom-worker"; - const plugin = await AdaptiveThinkingPlugin({ client } as never); - - await setReasoningEffort(plugin, { level: "high", persist: true }, toolContext); - - expect(client.session.promptAsync).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - agent: "custom-worker", - model: { providerID: "provider", modelID: "model" }, - variant: "high", - }), - }), - ); - }); + test("honors custom configuration and merges into an existing system prompt", async () => { + const { client } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { + toolName: "adjust_reasoning", + toolDescription: "Adjust it", + systemPrompt: "Prefer cheap.", + }); - test("returns no hooks when disabled", async () => { - const { client } = createClient("disabled-plugin"); - const plugin = await AdaptiveThinkingPlugin({ client } as never, { enabled: false }); + expect(plugin.tool.adjust_reasoning.description).toBe("Adjust it"); + expect(plugin.tool.set_reasoning_effort).toBeUndefined(); - expect(plugin).toEqual({}); + const system = ["Base prompt."]; + await transform(plugin, "custom", system); + expect(system).toHaveLength(1); + expect(system[0]?.startsWith("Base prompt.\n\nPrefer cheap.")).toBe(true); + expect(system[0]).not.toContain("You MUST manage"); + expect(system[0]).toContain("adjust_reasoning"); }); - test("logs configuration errors without creating hooks", async () => { - const { client } = createClient("invalid-config"); - const plugin = await AdaptiveThinkingPlugin({ client } as never, { enabled: "yes" }); + test("returns no hooks when disabled", async () => { + const { client } = createClient(); + expect(await AdaptiveThinkingPlugin({ client } as never, { enabled: false })).toEqual({}); + }); - expect(plugin).toEqual({}); - expect(client.tui.showToast).toHaveBeenCalledWith({ + test("reports invalid configuration and suppresses the toast when quiet", async () => { + const invalid = createClient(); + expect( + await AdaptiveThinkingPlugin({ client: invalid.client } as never, { enabled: "yes" }), + ).toEqual({}); + expect(invalid.client.tui.showToast).toHaveBeenCalledWith({ body: { variant: "error", - message: "Invalid Adaptive Thinking plugin configuration, see logs for details", + message: expect.stringContaining("Invalid Adaptive Thinking plugin configuration"), }, }); - expect(client.app.log).toHaveBeenCalledWith({ + expect(invalid.client.app.log).toHaveBeenCalledWith({ body: expect.objectContaining({ level: "error", - service: "opencode-adaptive-thinking", - message: expect.stringContaining("Invalid Adaptive Thinking plugin configuration"), + message: expect.stringContaining("enabled must be a boolean"), }), }); - }); - test("suppresses configuration error toast when quiet is enabled", async () => { - const { client } = createClient("quiet-invalid-config"); - const plugin = await AdaptiveThinkingPlugin({ client } as never, { + const quiet = createClient(); + await AdaptiveThinkingPlugin({ client: quiet.client } as never, { enabled: "yes", quiet: true, }); - - expect(plugin).toEqual({}); - expect(client.tui.showToast).not.toHaveBeenCalled(); - expect(client.app.log).toHaveBeenCalledWith({ - body: expect.objectContaining({ - level: "error", - service: "opencode-adaptive-thinking", - message: expect.stringContaining("Invalid Adaptive Thinking plugin configuration"), - }), - }); + expect(quiet.client.tui.showToast).not.toHaveBeenCalled(); }); - test("keeps log service name internal", async () => { - const sessionID = "fixed-service-name"; - const { client, toolContext } = createClient(sessionID); - client.session.messages.mockResolvedValueOnce({ - error: { data: { message: "unavailable" } }, - } as never); - const plugin = await AdaptiveThinkingPlugin({ client } as never, { - serviceName: "custom-service", - }); - - await setReasoningEffort(plugin, { level: "low", persist: false }, toolContext); + test("rejects an invalid level without prompting", async () => { + const { client, prompts } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); - expect(client.app.log).toHaveBeenCalledWith({ - body: expect.objectContaining({ - service: "opencode-adaptive-thinking", - }), - }); + expect(await set(plugin, "extreme", true, "invalid-level")).toContain( + "Invalid reasoning effort level", + ); + expect(prompts).toHaveLength(0); }); - test("uses configured tool name and description", async () => { - const sessionID = "custom-tool"; - const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); - const plugin = await AdaptiveThinkingPlugin({ client } as never, { - toolName: "adjust_reasoning", - toolDescription: "Adjust reasoning depth for the next response", - }); + test("reports models without reasoning variants", async () => { + const { client, prompts } = createClient([message("medium")], {}); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); - expect(plugin.tool?.set_reasoning_effort).toBeUndefined(); - expect(plugin.tool?.adjust_reasoning?.description).toBe( - "Adjust reasoning depth for the next response", + expect(await set(plugin, "high", true, "no-variants")).toContain( + "no valid reasoning effort levels", ); + expect(prompts).toHaveLength(0); + }); - await setReasoningEffort( - plugin, - { level: "high", persist: true }, - toolContext, - "adjust_reasoning", - ); + test("resets a temporary level to the level from the session history", async () => { + const { client, prompts } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); - expect(client.session.promptAsync).toHaveBeenCalledWith( - expect.objectContaining({ body: expect.objectContaining({ variant: "high" }) }), - ); - }); + await set(plugin, "low", false, "temporary"); + await idle(plugin, "temporary"); - test("uses configured system prompt guidance", async () => { - const sessionID = "custom-system-prompt"; - const { client } = createClient(sessionID, [createMessage("medium")]); - const plugin = await AdaptiveThinkingPlugin({ client } as never, { - systemPrompt: "Prefer the cheapest reasoning level that can safely complete the task.", + expect(prompts.map((prompt) => prompt.variant)).toEqual(["low", "medium"]); + expect(prompts[1]).toMatchObject({ + agent: "agent", + model: { providerID: "provider", modelID: "model" }, + parts: [{ synthetic: true, ignored: true }], }); - const system: string[] = []; - - await plugin["experimental.chat.system.transform"]!( - { - sessionID, - model: { variants }, - } as never, - { system }, - ); - - expect(system).toHaveLength(1); - expect(system[0]).toContain( - "Prefer the cheapest reasoning level that can safely complete the task.", - ); - expect(system[0]).not.toContain("You MUST manage reasoning effort actively"); }); - test("ignores cached reasoning effort when it is invalid for the current model", async () => { - const sessionID = "stale-cached-variant"; - const switchedModelVariants = { - low: {}, - medium: {}, - }; - const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); - const plugin = await AdaptiveThinkingPlugin({ client } as never); - const system: string[] = []; - - await setReasoningEffort(plugin, { level: "high", persist: true }, toolContext); - await plugin["experimental.chat.system.transform"]!( - { - sessionID, - model: { variants: switchedModelVariants }, - } as never, - { system }, - ); - - expect(system[0]).toContain("Current reasoning effort level: medium."); - expect(system[0]).toContain("Valid reasoning effort levels for this session: low, medium."); - expect(system[0]).not.toContain("Current reasoning effort level: high."); + test("keeps the original baseline across repeated temporary changes", async () => { + const changed = createClient([message("medium")]); + const changedPlugin: any = await AdaptiveThinkingPlugin({ client: changed.client } as never); + await set(changedPlugin, "low", false, "repeat-different"); + await set(changedPlugin, "high", false, "repeat-different"); + await idle(changedPlugin, "repeat-different"); + + const repeated = createClient([message("medium")]); + const repeatedPlugin: any = await AdaptiveThinkingPlugin({ client: repeated.client } as never); + await set(repeatedPlugin, "low", false, "repeat-same"); + await set(repeatedPlugin, "low", false, "repeat-same"); + await idle(repeatedPlugin, "repeat-same"); + + expect(changed.prompts.map((prompt) => prompt.variant)).toEqual(["low", "high", "medium"]); + expect(repeated.prompts.map((prompt) => prompt.variant)).toEqual(["low", "low", "medium"]); }); - test("falls back to the newest agent variant when messages do not expose a model variant", async () => { - const sessionID = "newest-agent-fallback"; - const { client } = createClient(sessionID, [ - createAgentMessage("old-agent"), - createAgentMessage("new-agent"), + test("keeps a temporary reset pending after a failed attempt", async () => { + const { client } = createClient([message("medium")]); + client.session.promptAsync + .mockResolvedValueOnce({ error: undefined }) + .mockResolvedValueOnce({ error: { data: { message: "unavailable" } } } as never) + .mockResolvedValueOnce({ error: undefined }); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); + + await set(plugin, "low", false, "reset-retry"); + await idle(plugin, "reset-retry"); + await idle(plugin, "reset-retry"); + + expect(client.session.promptAsync.mock.calls.map(([call]: any) => call.body.variant)).toEqual([ + "low", + "medium", + "medium", ]); - client.app.agents.mockResolvedValueOnce({ - data: [ - { name: "old-agent", variant: "high" }, - { name: "new-agent", variant: "low" }, - ], - error: undefined, - } as never); - const plugin = await AdaptiveThinkingPlugin({ client } as never); - const system: string[] = []; - - await plugin["experimental.chat.system.transform"]!( - { - sessionID, - model: { variants }, - } as never, - { system }, - ); - - expect(system[0]).toContain("Current reasoning effort level: low."); + expect(client.app.log).toHaveBeenCalled(); }); - test("resets a temporary reasoning effort once and keeps the reset prompt ignored", async () => { - const sessionID = "temporary-reset"; - const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); - const plugin = await AdaptiveThinkingPlugin({ client } as never); - - await setReasoningEffort(plugin, { level: "low", persist: false }, toolContext); - await plugin.event!({ - event: { type: "session.idle", properties: { sessionID } }, - } as never); - await plugin.event!({ - event: { type: "session.idle", properties: { sessionID } }, + test("reports a provider lookup failure without prompting", async () => { + const { client, prompts } = createClient([message("medium")]); + client.provider.list.mockResolvedValueOnce({ + data: undefined, + error: { message: "down" }, } as never); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); - expect(client.session.promptAsync).toHaveBeenCalledTimes(2); - const promptCalls = client.session.promptAsync.mock.calls as Array>; - const secondPrompt = promptCalls[1]?.[0]; - expect(secondPrompt).toMatchObject({ - body: { - agent: "agent", - model: { providerID: "provider", modelID: "model" }, - variant: "medium", - parts: [{ ignored: true, synthetic: true }], - }, - }); + expect(await set(plugin, "high", true, "provider-failure")).toContain( + "no valid reasoning effort levels", + ); + expect(prompts).toHaveLength(0); + expect(client.app.log).toHaveBeenCalled(); }); - test("evicts oldest session state when the cache is full", async () => { - const oldest = await touchTemporarySession("lru-oldest"); + test("does not reset a persisted level on idle", async () => { + const { client, prompts } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); - for (let i = 0; i < 500; i++) { - await touchTemporarySession(`lru-session-${i}`); - } - - await oldest.plugin.event!({ - event: { type: "session.idle", properties: { sessionID: "lru-oldest" } }, - } as never); - - expect(oldest.client.session.promptAsync).toHaveBeenCalledTimes(1); + await set(plugin, "high", true, "persisted"); + await idle(plugin, "persisted"); + expect(prompts).toHaveLength(1); }); - test("does not reset persisted reasoning effort on idle", async () => { - const sessionID = "persisted-effort"; - const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); - const plugin = await AdaptiveThinkingPlugin({ client } as never); - - await setReasoningEffort(plugin, { level: "high", persist: true }, toolContext); - await plugin.event!({ - event: { type: "session.idle", properties: { sessionID } }, + test("returns an error instead of throwing when messages cannot be read", async () => { + const { client } = createClient([message("medium")]); + client.session.messages.mockResolvedValueOnce({ + data: undefined, + error: { data: { message: "unavailable" } }, } as never); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); - expect(client.session.promptAsync).toHaveBeenCalledTimes(1); + expect(await set(plugin, "high", true, "fetch-failure")).toContain( + "could not read the session", + ); + expect(client.app.log).toHaveBeenCalled(); }); - test("rejects invalid reasoning effort levels before prompting", async () => { - const sessionID = "invalid-level"; - const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); - const plugin = await AdaptiveThinkingPlugin({ client } as never); + test("ignores a cached level that the current model does not support", async () => { + const { client } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); + await set(plugin, "high", true, "stale-cache"); - const result = await setReasoningEffort( - plugin, - { level: "extreme", persist: true }, - toolContext, - ); - - expect(result).toContain("Invalid reasoning effort level"); - expect(client.session.promptAsync).not.toHaveBeenCalled(); + const system: Array = []; + await transform(plugin, "stale-cache", system, { variants: { low: {}, medium: {} } }); + expect(system[0]).toContain("Valid reasoning effort levels for this session: low, medium."); + expect(system[0]).not.toContain("Current reasoning effort level: high."); }); - test("handles provider lookup failure without prompting", async () => { - const sessionID = "provider-lookup-failure"; - const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); - client.provider.list.mockResolvedValueOnce({ - error: { data: { message: "provider unavailable" } }, - } as never); - const plugin = await AdaptiveThinkingPlugin({ client } as never); - - const result = await setReasoningEffort(plugin, { level: "high", persist: true }, toolContext); + test("falls back to the agent variant when history has none", async () => { + const { client } = createClient([message(undefined, "worker")], variants, { worker: "low" }); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); - expect(result).toContain("no valid reasoning effort levels"); - expect(client.session.promptAsync).not.toHaveBeenCalled(); - expect(client.app.log).toHaveBeenCalledWith({ - body: expect.objectContaining({ - level: "error", - message: expect.stringContaining("Failed to retrieve providers"), - }), - }); + const system: Array = []; + await transform(plugin, "agent-fallback", system, {}); + expect(system[0]).toContain("Current reasoning effort level: low."); }); - test("retries a failed temporary reset and clears it after a later success", async () => { - const sessionID = "recoverable-reset-failure"; - const { client, toolContext } = createClient(sessionID, [createMessage("medium")]); - client.session.promptAsync - .mockResolvedValueOnce({ error: undefined } as never) - .mockResolvedValueOnce({ error: { data: { message: "reset failed" } } } as never) - .mockResolvedValueOnce({ error: undefined } as never); - const plugin = await AdaptiveThinkingPlugin({ client } as never); - - await setReasoningEffort(plugin, { level: "low", persist: false }, toolContext); - await plugin.event!({ - event: { type: "session.idle", properties: { sessionID } }, - } as never); - await plugin.event!({ - event: { type: "session.idle", properties: { sessionID } }, - } as never); - await plugin.event!({ - event: { type: "session.idle", properties: { sessionID } }, - } as never); + test("evicts the oldest session state when the cache is full", async () => { + const oldest = createClient([message("medium")]); + const oldestPlugin: any = await AdaptiveThinkingPlugin({ client: oldest.client } as never); + await set(oldestPlugin, "low", false, "lru-0"); - expect(client.session.promptAsync).toHaveBeenCalledTimes(3); - expect(client.app.log).toHaveBeenCalledWith({ - body: expect.objectContaining({ - level: "error", - message: expect.stringContaining("Failed to reset reasoning effort"), - }), - }); + for (let i = 1; i <= 500; i++) { + const { client } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); + await set(plugin, "low", false, `lru-${i}`); + } + + await idle(oldestPlugin, "lru-0"); + expect(oldest.prompts).toHaveLength(1); }); }); diff --git a/src/index.ts b/src/index.ts index 3b21c89..5924f17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,22 @@ import { tool, type Plugin } from "@opencode-ai/plugin"; -import type { Agent, Message, Model, Part, Provider } from "@opencode-ai/sdk/v2"; -import { ConfigSchema } from "./config.js"; +import type { Message, Part } from "@opencode-ai/sdk/v2"; import type { OpencodeClient } from "@opencode-ai/sdk"; +import { parseConfig } from "./config.js"; const z = tool.schema; const serviceName = "opencode-adaptive-thinking"; const maxSessionStateSize = 500; +const historyLimit = 20; +type ModelInfo = { providerID: string; modelID: string }; +type ProviderInfo = { + id: string; + models: Record }>; +}; +type SessionMessage = { info: Message; parts: Array }; type SessionState = { - currentVariant?: string; - persistedVariant?: string; - temporaryResetVariant?: string; - temporaryResetAgent?: string; - temporaryResetModel?: { providerID: string; modelID: string }; + variant?: string; + reset?: { level: string; agent: string; model?: ModelInfo }; }; class SessionStateCache { @@ -34,28 +38,32 @@ class SessionStateCache { update(entry); this.entries.set(sessionID, entry); - while (this.entries.size > this.maxSize) { - const oldestSessionID = this.entries.keys().next().value; - if (!oldestSessionID) break; - this.entries.delete(oldestSessionID); + if (this.entries.size > this.maxSize) { + const oldest = this.entries.keys().next().value; + if (oldest) this.entries.delete(oldest); } } } const state = new SessionStateCache(maxSessionStateSize); -type PromptAsyncOptions = Parameters[0]; -type PromptAsyncBody = NonNullable & { variant: string }; -type ModelInfo = { providerID: string; modelID: string }; +type PromptAsyncBody = NonNullable< + Parameters[0]["body"] +> & { variant: string }; export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { - const configParseResult = ConfigSchema.safeParse(options); - if (!configParseResult.success) { - const quiet = - typeof options === "object" && options !== null && "quiet" in options - ? options.quiet === true - : false; - if (!quiet) { + const logError = (message: string, error: unknown) => + client.app.log({ + body: { + service: serviceName, + level: "error", + message: `${message}: ${JSON.stringify(error)}`, + }, + }); + + const parsed = parseConfig(options); + if ("error" in parsed) { + if (options?.quiet !== true) { client.tui.showToast({ body: { variant: "error", @@ -63,154 +71,94 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { }, }); } - client.app.log({ - body: { - service: serviceName, - level: "error", - message: `Invalid Adaptive Thinking plugin configuration: ${configParseResult.error.message}`, - }, - }); + logError("Invalid Adaptive Thinking plugin configuration", parsed.error); return {}; } - const config = configParseResult.data; + const config = parsed.config; if (!config.enabled) return {}; - const getModelVariants = (model: Model | undefined): string[] => { - const variants = model?.variants; - if (!variants) return []; - return Object.keys(variants); + const getModelVariants = (model: unknown) => { + const variants = (model as { variants?: Record } | undefined)?.variants; + return Object.keys(variants ?? {}); }; - const sendVariantPrompt = async ( - sessionID: string, - variant: string, - text: string, - agent: string, - model?: ModelInfo, - ignored = true, - ) => { - const body: PromptAsyncBody = { - noReply: true, - parts: [ - { - type: "text", - text, - synthetic: true, - ignored, - }, - ], - agent, - variant, - }; + let providers: Array | undefined; - if (model) { - body.model = model; + const variantsFor = async (model: ModelInfo) => { + if (!providers) { + const response = await client.provider.list(); + if (response.error) { + logError("Failed to retrieve providers", response.error); + return []; + } + providers = (response.data?.all ?? []) as unknown as Array; } - - return client.session.promptAsync({ - path: { id: sessionID }, - body, - }); + const provider = providers.find((candidate) => candidate.id === model.providerID); + return getModelVariants(provider?.models[model.modelID]); }; - const resolveValidVariants = async (sessionID: string, model?: Model): Promise => { - const inputModelVariants = getModelVariants(model); - if (inputModelVariants.length > 0) return inputModelVariants; - - const messagesResponse = await client.session.messages({ + const fetchMessages = async (sessionID: string) => { + const response = await client.session.messages({ path: { id: sessionID }, + query: { limit: historyLimit }, }); - if (messagesResponse.error) { - client.app.log({ - body: { - service: serviceName, - level: "error", - message: `Failed to retrieve messages for session ${sessionID}: ${JSON.stringify(messagesResponse.error.data)}`, - }, - }); - return []; - } - const modelInfo = resolveLatestModelInfo(messagesResponse.data); - if (!modelInfo) return []; - - const providers = await client.provider.list(); - if (providers.error) { - client.app.log({ - body: { - service: serviceName, - level: "error", - message: `Failed to retrieve providers for session ${sessionID}: ${JSON.stringify(providers.error)}`, - }, - }); - return []; + if (response.error) { + logError(`Failed to retrieve messages for session ${sessionID}`, response.error); + return; } - const provider = providers.data?.all.find((p) => p.id === modelInfo.providerID) as - | Provider - | undefined; - if (!provider) return []; - - return getModelVariants(provider.models[modelInfo.modelID] as Model | undefined); + return (response.data ?? []) as Array; }; - const resolveLatestModelInfo = ( - data: unknown, - ): { providerID: string; modelID: string } | undefined => { - const messages = data as Array<{ info: Message; parts: Array }>; - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]!; - if ("model" in message.info) { - const { providerID, modelID } = message.info.model; - if (providerID && modelID) { - return { providerID, modelID }; - } - } - } - return; + const modelOf = (info: Message): ModelInfo => + "model" in info + ? { providerID: info.model.providerID, modelID: info.model.modelID } + : { providerID: info.providerID, modelID: info.modelID }; + + const latestModel = (messages: Array): ModelInfo | undefined => { + const last = messages.at(-1)?.info; + return last ? modelOf(last) : undefined; }; - const resolveCurrentVariant = async (sessionID: string): Promise => { - const messagesResponse = await client.session.messages({ - path: { id: sessionID }, - }); - if (messagesResponse.error) { - client.app.log({ - body: { - service: serviceName, - level: "error", - message: `Failed to retrieve messages for session ${sessionID}: ${JSON.stringify(messagesResponse.error.data)}`, - }, - }); - return; - } - let agentName: string | undefined; - const messages = messagesResponse.data as Array<{ info: Message; parts: Array }>; + const latestVariant = async (messages: Array) => { + let agent: string | undefined; for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]!; - if ("model" in message.info && message.info.model.variant) { - return message.info.model.variant; - } - agentName ??= message.info.agent; + const info = messages[i]!.info; + const variant = "model" in info ? info.model.variant : info.variant; + if (variant) return variant; + agent ??= info.agent; } + if (!agent) return; - if (!agentName) return; - - const agentsResponse = await client.app.agents(); - if (agentsResponse.error) { - client.app.log({ - body: { - service: serviceName, - level: "error", - message: `Failed to retrieve agents for session ${sessionID}: ${JSON.stringify(agentsResponse.error)}`, - }, - }); + const response = await client.app.agents(); + if (response.error) { + logError("Failed to retrieve agents", response.error); return; } - const agents = agentsResponse.data; - const agent = agents?.find((a) => a.name === agentName) as Agent | undefined; - return agent?.variant; + const match = response.data?.find((candidate) => candidate.name === agent) as + | { variant?: string } + | undefined; + return match?.variant; }; + const sendVariantPrompt = ( + sessionID: string, + variant: string, + text: string, + agent: string, + model?: ModelInfo, + ) => + client.session.promptAsync({ + path: { id: sessionID }, + body: { + noReply: true, + parts: [{ type: "text", text, synthetic: true, ignored: true }], + agent, + variant, + ...(model ? { model } : {}), + } as PromptAsyncBody, + }); + return { tool: { [config.toolName]: tool({ @@ -230,52 +178,36 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { ), }, execute: async ({ level, persist }, { sessionID, agent }) => { - const validVariants = await resolveValidVariants(sessionID); - if (validVariants.length === 0) { + const messages = await fetchMessages(sessionID); + if (!messages) return "Failed to set reasoning effort: could not read the session"; + + const model = latestModel(messages); + const variants = model ? await variantsFor(model) : []; + if (variants.length === 0) { return "Failed to set reasoning effort: no valid reasoning effort levels are available for this session"; } - if (!validVariants.includes(level)) { - return `Invalid reasoning effort level: ${level}. Valid levels: ${validVariants.join(", ")}.`; + if (!variants.includes(level)) { + return `Invalid reasoning effort level: ${level}. Valid levels: ${variants.join(", ")}.`; } - const sessionState = state.get(sessionID); - const resetVariant = persist - ? undefined - : (sessionState?.persistedVariant ?? (await resolveCurrentVariant(sessionID))); - const currentModel = resolveLatestModelInfo( - (await client.session.messages({ path: { id: sessionID } })).data, - ); - - const promptResponse = await sendVariantPrompt( + const baseline = state.get(sessionID)?.reset?.level ?? (await latestVariant(messages)); + const response = await sendVariantPrompt( sessionID, level, `Reasoning effort set to ${level}`, agent, - currentModel, + model, ); - if (promptResponse.error) { - return `Failed to set reasoning effort: ${JSON.stringify(promptResponse.error.data)}`; + if (response.error) { + return `Failed to set reasoning effort: ${JSON.stringify(response.error)}`; } state.update(sessionID, (entry) => { - entry.currentVariant = level; - if (persist) { - entry.persistedVariant = level; - delete entry.temporaryResetVariant; - delete entry.temporaryResetAgent; - delete entry.temporaryResetModel; - } else if (resetVariant && resetVariant !== level) { - entry.temporaryResetVariant = resetVariant; - entry.temporaryResetAgent = agent; - if (currentModel) { - entry.temporaryResetModel = currentModel; - } else { - delete entry.temporaryResetModel; - } + entry.variant = level; + if (persist || !baseline || baseline === level || !variants.includes(baseline)) { + delete entry.reset; } else { - delete entry.temporaryResetVariant; - delete entry.temporaryResetAgent; - delete entry.temporaryResetModel; + entry.reset ??= { level: baseline, agent, ...(model ? { model } : {}) }; } }); @@ -284,68 +216,69 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { }), }, event: async ({ event }) => { - if (event.type === "session.idle") { - const sessionID = event.properties.sessionID; - const sessionState = state.get(sessionID); - const resetVariant = sessionState?.temporaryResetVariant; - const resetAgent = sessionState?.temporaryResetAgent; - const resetModel = sessionState?.temporaryResetModel; - if (!resetVariant || !resetAgent) return; - - const promptResponse = await sendVariantPrompt( - sessionID, - resetVariant, - `Reasoning effort reset to ${resetVariant}.`, - resetAgent, - resetModel, - ); - if (promptResponse.error) { - client.app.log({ - body: { - service: serviceName, - level: "error", - message: `Failed to reset reasoning effort on session idle: ${JSON.stringify(promptResponse.error.data)}`, - }, - }); - return; - } - - state.update(sessionID, (entry) => { - entry.currentVariant = resetVariant; - delete entry.temporaryResetVariant; - delete entry.temporaryResetAgent; - delete entry.temporaryResetModel; - }); + if (event.type !== "session.idle") return; + + const sessionID = event.properties.sessionID; + const reset = state.get(sessionID)?.reset; + if (!reset) return; + + const response = await sendVariantPrompt( + sessionID, + reset.level, + `Reasoning effort reset to ${reset.level}.`, + reset.agent, + reset.model, + ); + if (response.error) { + logError("Failed to reset reasoning effort on session idle", response.error); return; } + + state.update(sessionID, (entry) => { + entry.variant = reset.level; + delete entry.reset; + }); }, "experimental.chat.system.transform": async ({ sessionID, model }, { system }) => { if (!sessionID) return; - const variants = await resolveValidVariants(sessionID, model as Model); - if (variants.length === 0) return; + let variants = getModelVariants(model); + let variant = state.get(sessionID)?.variant; + if (variant && !variants.includes(variant)) variant = undefined; - const sessionState = state.get(sessionID); - const cachedVariant = sessionState?.currentVariant ?? sessionState?.persistedVariant; - let variant = cachedVariant && variants.includes(cachedVariant) ? cachedVariant : undefined; - if (!variant) { - const resolvedVariant = await resolveCurrentVariant(sessionID); - if (resolvedVariant && variants.includes(resolvedVariant)) { - variant = resolvedVariant; - state.update(sessionID, (entry) => { - entry.currentVariant = resolvedVariant; - }); + if (variants.length === 0 || !variant) { + const messages = await fetchMessages(sessionID); + if (!messages) return; + + if (variants.length === 0) { + const info = latestModel(messages); + variants = info ? await variantsFor(info) : []; + if (variants.length === 0) return; + } + + if (!variant) { + const resolved = await latestVariant(messages); + if (resolved && variants.includes(resolved)) { + variant = resolved; + state.update(sessionID, (entry) => { + entry.variant = resolved; + }); + } } } - system.push( - config.systemPrompt.trim() + - " " + - (variant ? `Current reasoning effort level: ${variant}. ` : "") + - `Valid reasoning effort levels for this session: ${variants.join(", ")}. ` + - `To change your reasoning effort, use the \`${config.toolName}\` tool with one of the valid levels. ` + - "Only call it when the task complexity justifies changing levels.", - ); + const guidance = [ + config.systemPrompt.trim(), + variant ? `Current reasoning effort level: ${variant}.` : "", + `Valid reasoning effort levels for this session: ${variants.join(", ")}.`, + `To change your reasoning effort, use the \`${config.toolName}\` tool with one of the valid levels.`, + "Only call it when the task complexity justifies changing levels.", + ] + .filter(Boolean) + .join(" "); + + const existing = system[0]; + system[0] = existing ? `${existing}\n\n${guidance}` : guidance; }, }; }; From a8dae1a97ab54c5c76269220d2a4473c2a98aefd Mon Sep 17 00:00:00 2001 From: BaconDroid <11067111+BaconDroid@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:07:41 -0400 Subject: [PATCH 2/6] fix: keep the last known level as a reset baseline Fall back to the cached current level when the recent history window carries no variant, and read model identity and variants from assistant messages too. Reuse the SDK provider type for the cached provider list. --- src/index.test.ts | 22 ++++++++++++++++++++++ src/index.ts | 15 +++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 49d479e..79f171f 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -12,6 +12,17 @@ const message = (variant?: string, agent = "agent") => ({ parts: [], }); +const assistant = (variant?: string) => ({ + info: { + role: "assistant", + agent: "agent", + providerID: "provider", + modelID: "model", + ...(variant ? { variant } : {}), + }, + parts: [], +}); + const createClient = ( messages: Array = [], modelVariants: Record = variants, @@ -170,6 +181,17 @@ describe("AdaptiveThinkingPlugin", () => { }); }); + test("reads the model and variant from assistant messages", async () => { + const { client, prompts } = createClient([message("medium"), assistant("high")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); + + await set(plugin, "low", false, "assistant-shape"); + await idle(plugin, "assistant-shape"); + + expect(prompts[0].model).toEqual({ providerID: "provider", modelID: "model" }); + expect(prompts.map((prompt) => prompt.variant)).toEqual(["low", "high"]); + }); + test("keeps the original baseline across repeated temporary changes", async () => { const changed = createClient([message("medium")]); const changedPlugin: any = await AdaptiveThinkingPlugin({ client: changed.client } as never); diff --git a/src/index.ts b/src/index.ts index 5924f17..5f07ab5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { tool, type Plugin } from "@opencode-ai/plugin"; -import type { Message, Part } from "@opencode-ai/sdk/v2"; +import type { Message, Part, Provider } from "@opencode-ai/sdk/v2"; import type { OpencodeClient } from "@opencode-ai/sdk"; import { parseConfig } from "./config.js"; @@ -9,10 +9,6 @@ const maxSessionStateSize = 500; const historyLimit = 20; type ModelInfo = { providerID: string; modelID: string }; -type ProviderInfo = { - id: string; - models: Record }>; -}; type SessionMessage = { info: Message; parts: Array }; type SessionState = { variant?: string; @@ -83,7 +79,7 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { return Object.keys(variants ?? {}); }; - let providers: Array | undefined; + let providers: Array | undefined; const variantsFor = async (model: ModelInfo) => { if (!providers) { @@ -92,7 +88,7 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { logError("Failed to retrieve providers", response.error); return []; } - providers = (response.data?.all ?? []) as unknown as Array; + providers = (response.data?.all ?? []) as unknown as Array; } const provider = providers.find((candidate) => candidate.id === model.providerID); return getModelVariants(provider?.models[model.modelID]); @@ -190,7 +186,10 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { return `Invalid reasoning effort level: ${level}. Valid levels: ${variants.join(", ")}.`; } - const baseline = state.get(sessionID)?.reset?.level ?? (await latestVariant(messages)); + const baseline = + state.get(sessionID)?.reset?.level ?? + state.get(sessionID)?.variant ?? + (await latestVariant(messages)); const response = await sendVariantPrompt( sessionID, level, From 970b7bee9e4628a87932eaa16c5b40dbb04220c9 Mon Sep 17 00:00:00 2001 From: BaconDroid <11067111+BaconDroid@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:23:11 -0400 Subject: [PATCH 3/6] feat: select reasoning effort from request complexity Add an opt-in autoSelect mode. The chat.message hook classifies each user request and writes the chosen variant onto the message, which OpenCode persists and uses for the turn. The level is carried across turns until the agent changes it or the model changes. --- .changeset/auto-effort.md | 5 ++++ README.md | 3 +++ src/complexity.test.ts | 21 +++++++++++++++ src/complexity.ts | 54 +++++++++++++++++++++++++++++++++++++++ src/config.ts | 4 ++- src/index.test.ts | 39 ++++++++++++++++++++++++++++ src/index.ts | 27 ++++++++++++++++++++ 7 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 .changeset/auto-effort.md create mode 100644 src/complexity.test.ts create mode 100644 src/complexity.ts diff --git a/.changeset/auto-effort.md b/.changeset/auto-effort.md new file mode 100644 index 0000000..be0de14 --- /dev/null +++ b/.changeset/auto-effort.md @@ -0,0 +1,5 @@ +--- +"opencode-adaptive-thinking": minor +--- + +Add an opt-in `autoSelect` mode that classifies each user request and sets the reasoning effort from the current model's variants. diff --git a/README.md b/README.md index c8c1191..09ddbd8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The plugin accepts these optional settings: - `enabled`: enable or disable the plugin without removing it from config. Defaults to `true`. - `quiet`: suppress toast notifications for configuration errors. Defaults to `false`. +- `autoSelect`: choose an effort level from each user request and apply it to the turn. Defaults to `false`. - `toolName`: name of the reasoning-effort tool exposed to agents. Defaults to `set_reasoning_effort`. - `toolDescription`: description for the reasoning-effort tool. Defaults to `Set your reasoning effort`. - `systemPrompt`: custom adaptive-thinking guidance injected into the system prompt. @@ -89,6 +90,8 @@ Example persisted change: The system prompt lists the valid levels for the current session. Cached session state is only used when it is still valid for the current model, so switching models will not advertise or reuse an invalid effort level. +With `autoSelect` enabled, the plugin classifies each user request and sets the effort from the available variants for the current model, ignoring `none`. The chosen level is carried across turns until the agent changes it or the model changes. This mode manages the level, so a variant chosen only in the UI is overridden. + Adaptive-thinking guidance is merged into the first existing system prompt instead of a separate system message, so strict chat templates that allow only one system message keep working. ## Troubleshooting diff --git a/src/complexity.test.ts b/src/complexity.test.ts new file mode 100644 index 0000000..7684d20 --- /dev/null +++ b/src/complexity.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { classifyPrompt, pickVariant } from "./complexity"; + +test("classifies prompt complexity", () => { + expect(classifyPrompt("fix typo in the README")).toBe("simple"); + expect(classifyPrompt("add a test for the parser")).toBe("normal"); + expect(classifyPrompt("debug the flaky test")).toBe("complex"); + expect(classifyPrompt("debug the flaky test and trace the root cause across modules")).toBe( + "deep", + ); +}); + +test("maps complexity to an available variant", () => { + const all = ["none", "low", "medium", "high", "xhigh"]; + expect(pickVariant(all, "simple")).toBe("low"); + expect(pickVariant(all, "normal")).toBe("medium"); + expect(pickVariant(all, "complex")).toBe("high"); + expect(pickVariant(all, "deep")).toBe("xhigh"); + expect(pickVariant(["low"], "deep")).toBe("low"); + expect(pickVariant([], "normal")).toBeUndefined(); +}); diff --git a/src/complexity.ts b/src/complexity.ts new file mode 100644 index 0000000..e67fa99 --- /dev/null +++ b/src/complexity.ts @@ -0,0 +1,54 @@ +export type Complexity = "simple" | "normal" | "complex" | "deep"; + +const signals: Array<[RegExp, number]> = [ + [/\b(debug|investigate|trace)\b/i, 2], + [/\b(root cause|flaky|race|deadlock|leak)\b/i, 2], + [ + /\b(architecture|architect|design|plan|refactor|migrat\w*|review|audit|security|performance|optimi[sz]e)\b/i, + 2, + ], + [/\b(error|exception|failing|fails?|failure|broken|bug|why)\b/i, 2], + [/\b(test|tests|coverage|benchmark)\b/i, 1], + [/\b(multi[- ]file|across|module|modules|system|end[- ]to[- ]end)\b/i, 1], + [/[?][\s\S]*[?]/, 1], + [/```/, 1], +]; + +const simpleSignals = /\b(typo|rename|format|lint|thanks|thank you|hello|hi|hey)\b/i; + +export const classifyPrompt = (text: string): Complexity => { + const input = text.trim(); + if (input.length === 0) return "normal"; + + let score = signals.reduce( + (total, [pattern, weight]) => total + (pattern.test(input) ? weight : 0), + 0, + ); + if (input.length > 400) score += 1; + if (input.length > 1200) score += 2; + if (input.length < 200 && simpleSignals.test(input)) score -= 3; + + if (score >= 6) return "deep"; + if (score >= 3) return "complex"; + if (score <= 0 && input.length < 160) return "simple"; + return "normal"; +}; + +export const pickVariant = ( + variants: Array, + complexity: Complexity, +): string | undefined => { + const usable = variants.filter((variant) => !/^(none|off|disabled)$/i.test(variant)); + if (usable.length === 0) return undefined; + if (usable.length === 1) return usable[0]; + + const last = usable.length - 1; + const index = { + simple: 0, + normal: Math.floor(last / 2), + complex: Math.round(last * 0.75), + deep: last, + }[complexity]; + + return usable[index]; +}; diff --git a/src/config.ts b/src/config.ts index 8213a1f..3bbb234 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,7 @@ export const defaultSystemPrompt = export type Config = { enabled: boolean; quiet: boolean; + autoSelect: boolean; toolName: string; toolDescription: string; systemPrompt: string; @@ -15,6 +16,7 @@ export type Config = { const defaults: Config = { enabled: true, quiet: false, + autoSelect: false, toolName: "set_reasoning_effort", toolDescription: "Set your reasoning effort", systemPrompt: defaultSystemPrompt, @@ -29,7 +31,7 @@ export const parseConfig = (options: unknown): { config: Config } | { error: str const input = options as Record; const config = { ...defaults }; - for (const key of ["enabled", "quiet"] as const) { + for (const key of ["enabled", "quiet", "autoSelect"] as const) { const value = input[key]; if (value === undefined) continue; if (typeof value !== "boolean") return { error: `${key} must be a boolean` }; diff --git a/src/index.test.ts b/src/index.test.ts index 79f171f..ed7bb47 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -115,6 +115,45 @@ describe("AdaptiveThinkingPlugin", () => { expect(system[0]).toContain("adjust_reasoning"); }); + test("selects and carries an effort when autoSelect is enabled", async () => { + const { client } = createClient(); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { autoSelect: true }); + const input = { sessionID: "auto-select", model: { providerID: "provider", modelID: "model" } }; + + const first = message().info; + await plugin["chat.message"](input, { + message: first, + parts: [{ type: "text", text: "fix a typo" }], + }); + expect((first.model as { variant?: string }).variant).toBe("low"); + + const second = message().info; + await plugin["chat.message"](input, { + message: second, + parts: [{ type: "text", text: "debug the flaky race" }], + }); + expect((second.model as { variant?: string }).variant).toBe("low"); + }); + + test("leaves messages alone when autoSelect is disabled or the prompt is ignored", async () => { + const { client } = createClient(); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); + + const manual = message().info; + await plugin["chat.message"]( + { sessionID: "auto-off", model: { providerID: "provider", modelID: "model" } }, + { message: manual, parts: [{ type: "text", text: "debug the flaky race" }] }, + ); + expect((manual.model as { variant?: string }).variant).toBeUndefined(); + + const ignored = message().info; + await plugin["chat.message"]( + { sessionID: "auto-off", model: { providerID: "provider", modelID: "model" } }, + { message: ignored, parts: [{ type: "text", text: "debug the flaky race", ignored: true }] }, + ); + expect((ignored.model as { variant?: string }).variant).toBeUndefined(); + }); + test("returns no hooks when disabled", async () => { const { client } = createClient(); expect(await AdaptiveThinkingPlugin({ client } as never, { enabled: false })).toEqual({}); diff --git a/src/index.ts b/src/index.ts index 5f07ab5..21d2f91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { tool, type Plugin } from "@opencode-ai/plugin"; import type { Message, Part, Provider } from "@opencode-ai/sdk/v2"; import type { OpencodeClient } from "@opencode-ai/sdk"; import { parseConfig } from "./config.js"; +import { classifyPrompt, pickVariant } from "./complexity.js"; const z = tool.schema; const serviceName = "opencode-adaptive-thinking"; @@ -137,6 +138,15 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { return match?.variant; }; + const promptText = (parts: Array) => { + if (parts.some((part) => part.type === "text" && part.ignored)) return; + return parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + .trim(); + }; + const sendVariantPrompt = ( sessionID: string, variant: string, @@ -214,6 +224,23 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { }, }), }, + "chat.message": async ({ sessionID }, { message, parts }) => { + if (!config.autoSelect) return; + + const text = promptText(parts); + if (text === undefined) return; + + const variants = await variantsFor(message.model); + const cached = state.get(sessionID)?.variant; + const level = + cached && variants.includes(cached) ? cached : pickVariant(variants, classifyPrompt(text)); + if (!level) return; + + (message.model as { variant?: string }).variant = level; + state.update(sessionID, (entry) => { + entry.variant = level; + }); + }, event: async ({ event }) => { if (event.type !== "session.idle") return; From 6334f9634dd724e304022156165bf8db30a9f76c Mon Sep 17 00:00:00 2001 From: BaconDroid <11067111+BaconDroid@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:08:40 -0400 Subject: [PATCH 4/6] fix: honor explicit variants in autoSelect Respect a variant supplied with the request and treat image-only prompts as no signal. Document that autoSelect classifies the first request and carries the level instead of reclassifying every turn. --- .changeset/auto-effort.md | 2 +- README.md | 4 ++-- src/index.test.ts | 23 +++++++++++++++++++++++ src/index.ts | 13 +++++++++++-- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.changeset/auto-effort.md b/.changeset/auto-effort.md index be0de14..0ad8828 100644 --- a/.changeset/auto-effort.md +++ b/.changeset/auto-effort.md @@ -2,4 +2,4 @@ "opencode-adaptive-thinking": minor --- -Add an opt-in `autoSelect` mode that classifies each user request and sets the reasoning effort from the current model's variants. +Add an opt-in `autoSelect` mode that classifies the first user request in a session and carries the chosen reasoning effort until the agent, an explicit variant, or a model change overrides it. diff --git a/README.md b/README.md index 09ddbd8..d028e6f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The plugin accepts these optional settings: - `enabled`: enable or disable the plugin without removing it from config. Defaults to `true`. - `quiet`: suppress toast notifications for configuration errors. Defaults to `false`. -- `autoSelect`: choose an effort level from each user request and apply it to the turn. Defaults to `false`. +- `autoSelect`: choose an initial effort level from the first user request in a session and carry it across turns. Defaults to `false`. - `toolName`: name of the reasoning-effort tool exposed to agents. Defaults to `set_reasoning_effort`. - `toolDescription`: description for the reasoning-effort tool. Defaults to `Set your reasoning effort`. - `systemPrompt`: custom adaptive-thinking guidance injected into the system prompt. @@ -90,7 +90,7 @@ Example persisted change: The system prompt lists the valid levels for the current session. Cached session state is only used when it is still valid for the current model, so switching models will not advertise or reuse an invalid effort level. -With `autoSelect` enabled, the plugin classifies each user request and sets the effort from the available variants for the current model, ignoring `none`. The chosen level is carried across turns until the agent changes it or the model changes. This mode manages the level, so a variant chosen only in the UI is overridden. +With `autoSelect` enabled, the plugin classifies the first user request in a session and sets the effort from the available variants for the current model, ignoring `none`. The chosen level is carried across turns until the agent changes it, the model changes, or a request carries an explicit variant. An explicit variant (UI picker, CLI flag, or API field) is honored and becomes the level carried forward. Adaptive-thinking guidance is merged into the first existing system prompt instead of a separate system message, so strict chat templates that allow only one system message keep working. diff --git a/src/index.test.ts b/src/index.test.ts index ed7bb47..f9ddc7d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -135,6 +135,29 @@ describe("AdaptiveThinkingPlugin", () => { expect((second.model as { variant?: string }).variant).toBe("low"); }); + test("honors an explicit variant in autoSelect mode", async () => { + const { client } = createClient(); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { autoSelect: true }); + const input = { + sessionID: "auto-explicit", + model: { providerID: "provider", modelID: "model" }, + }; + + const first = message().info; + await plugin["chat.message"]( + { ...input, variant: "high" }, + { message: first, parts: [{ type: "text", text: "fix a typo" }] }, + ); + expect((first.model as { variant?: string }).variant).toBe("high"); + + const second = message().info; + await plugin["chat.message"](input, { + message: second, + parts: [{ type: "text", text: "fix a typo" }], + }); + expect((second.model as { variant?: string }).variant).toBe("high"); + }); + test("leaves messages alone when autoSelect is disabled or the prompt is ignored", async () => { const { client } = createClient(); const plugin: any = await AdaptiveThinkingPlugin({ client } as never); diff --git a/src/index.ts b/src/index.ts index 21d2f91..086b408 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,11 +140,12 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { const promptText = (parts: Array) => { if (parts.some((part) => part.type === "text" && part.ignored)) return; - return parts + const text = parts .filter((part) => part.type === "text") .map((part) => part.text) .join("\n") .trim(); + return text.length > 0 ? text : undefined; }; const sendVariantPrompt = ( @@ -224,12 +225,20 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { }, }), }, - "chat.message": async ({ sessionID }, { message, parts }) => { + "chat.message": async ({ sessionID, variant }, { message, parts }) => { if (!config.autoSelect) return; const text = promptText(parts); if (text === undefined) return; + if (variant) { + (message.model as { variant?: string }).variant = variant; + state.update(sessionID, (entry) => { + entry.variant = variant; + }); + return; + } + const variants = await variantsFor(message.model); const cached = state.get(sessionID)?.variant; const level = From 57dba942043d8a2d82ff26bfedc233ccf9391d89 Mon Sep 17 00:00:00 2001 From: BaconDroid <11067111+BaconDroid@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:26:32 -0400 Subject: [PATCH 5/6] fix: give the agent a per-turn effort recommendation Classify each user turn and inject a specific instruction: call the tool with the recommended level when it differs, or do not call when the current level fits. Replace the blanket reassessment guidance with a shorter prompt that relies on the recommendation. --- .changeset/effort-recommendation.md | 5 +++ README.md | 2 ++ src/config.ts | 6 ++-- src/index.test.ts | 36 ++++++++++++++++++++-- src/index.ts | 48 +++++++++++++++++++++++------ 5 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 .changeset/effort-recommendation.md diff --git a/.changeset/effort-recommendation.md b/.changeset/effort-recommendation.md new file mode 100644 index 0000000..81ebe97 --- /dev/null +++ b/.changeset/effort-recommendation.md @@ -0,0 +1,5 @@ +--- +"opencode-adaptive-thinking": patch +--- + +Recommend a reasoning effort for each user turn and tell the agent when not to change it. The default guidance now relies on that recommendation instead of a blanket per-turn reassessment instruction. diff --git a/README.md b/README.md index d028e6f..29aee54 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ The system prompt lists the valid levels for the current session. Cached session With `autoSelect` enabled, the plugin classifies the first user request in a session and sets the effort from the available variants for the current model, ignoring `none`. The chosen level is carried across turns until the agent changes it, the model changes, or a request carries an explicit variant. An explicit variant (UI picker, CLI flag, or API field) is honored and becomes the level carried forward. +When `autoSelect` is off, the plugin still classifies each user turn and adds a recommendation to the guidance. If the recommended level differs from the current one, the agent is told to call the tool with that level; if it matches, the agent is told not to call. The default guidance relies on this recommendation instead of a blanket reassessment instruction. + Adaptive-thinking guidance is merged into the first existing system prompt instead of a separate system message, so strict chat templates that allow only one system message keep working. ## Troubleshooting diff --git a/src/config.ts b/src/config.ts index 3bbb234..9c367dc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,6 @@ export const defaultSystemPrompt = - "You MUST manage reasoning effort actively. " + - "Lower it before trivial or routine turns; raise it for ambiguity, debugging, risky changes, or multi-step synthesis. " + - "Reassess at turn start, after meaningful new evidence, and when the task shifts. " + - "NEVER leave the current level unchanged by inertia, and NEVER reply to a trivial turn before considering a downshift."; + "Match reasoning effort to the work: lower it for routine or trivial turns, raise it for ambiguity, debugging, risky changes, or multi-step synthesis. " + + "Use the per-turn recommendation when one is provided; otherwise call the tool only when the current level no longer fits the task."; export type Config = { enabled: boolean; diff --git a/src/index.test.ts b/src/index.test.ts index f9ddc7d..65be64e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -88,7 +88,7 @@ describe("AdaptiveThinkingPlugin", () => { const system: Array = []; await transform(plugin, "defaults", system); - expect(system[0]).toContain("You MUST manage reasoning effort actively"); + expect(system[0]).toContain("Match reasoning effort to the work"); expect(system[0]).toContain("Current reasoning effort level: high."); expect(system[0]).toContain( "Valid reasoning effort levels for this session: none, low, medium, high, xhigh.", @@ -111,7 +111,7 @@ describe("AdaptiveThinkingPlugin", () => { await transform(plugin, "custom", system); expect(system).toHaveLength(1); expect(system[0]?.startsWith("Base prompt.\n\nPrefer cheap.")).toBe(true); - expect(system[0]).not.toContain("You MUST manage"); + expect(system[0]).not.toContain("Match reasoning effort"); expect(system[0]).toContain("adjust_reasoning"); }); @@ -177,6 +177,38 @@ describe("AdaptiveThinkingPlugin", () => { expect((ignored.model as { variant?: string }).variant).toBeUndefined(); }); + test("recommends an effort change only when the turn needs one", async () => { + const { client } = createClient([message("low")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); + const input = { sessionID: "recommend", model: { providerID: "provider", modelID: "model" } }; + + await plugin["chat.message"](input, { + message: message("low").info, + parts: [{ type: "text", text: "debug the flaky race" }], + }); + const system: Array = []; + await transform(plugin, "recommend", system); + expect(system[0]).toContain("This turn suggests high effort"); + expect(system[0]).toContain("with `high`"); + + await plugin["chat.message"](input, { + message: message("low").info, + parts: [{ type: "text", text: "fix a typo" }], + }); + const next: Array = []; + await transform(plugin, "recommend", next); + expect(next[0]).toContain("do not call `set_reasoning_effort`"); + }); + + test("tells the agent that effort is managed when autoSelect is enabled", async () => { + const { client } = createClient(); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { autoSelect: true }); + const system: Array = []; + + await transform(plugin, "auto-instruction", system); + expect(system[0]).toContain("selected automatically"); + }); + test("returns no hooks when disabled", async () => { const { client } = createClient(); expect(await AdaptiveThinkingPlugin({ client } as never, { enabled: false })).toEqual({}); diff --git a/src/index.ts b/src/index.ts index 086b408..1282e95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ type ModelInfo = { providerID: string; modelID: string }; type SessionMessage = { info: Message; parts: Array }; type SessionState = { variant?: string; + recommendation?: string; reset?: { level: string; agent: string; model?: ModelInfo }; }; @@ -226,8 +227,6 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { }), }, "chat.message": async ({ sessionID, variant }, { message, parts }) => { - if (!config.autoSelect) return; - const text = promptText(parts); if (text === undefined) return; @@ -235,19 +234,40 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { (message.model as { variant?: string }).variant = variant; state.update(sessionID, (entry) => { entry.variant = variant; + delete entry.recommendation; }); return; } const variants = await variantsFor(message.model); - const cached = state.get(sessionID)?.variant; - const level = - cached && variants.includes(cached) ? cached : pickVariant(variants, classifyPrompt(text)); - if (!level) return; + const current = variant ?? (message.model as { variant?: string }).variant; + + if (config.autoSelect) { + const cached = state.get(sessionID)?.variant; + const level = + cached && variants.includes(cached) + ? cached + : pickVariant(variants, classifyPrompt(text)); + if (!level) return; + + (message.model as { variant?: string }).variant = level; + state.update(sessionID, (entry) => { + entry.variant = level; + delete entry.recommendation; + }); + return; + } + + if (current) { + state.update(sessionID, (entry) => { + entry.variant = current; + }); + } - (message.model as { variant?: string }).variant = level; + const recommended = pickVariant(variants, classifyPrompt(text)); state.update(sessionID, (entry) => { - entry.variant = level; + if (recommended) entry.recommendation = recommended; + else delete entry.recommendation; }); }, event: async ({ event }) => { @@ -302,12 +322,20 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { } } + const recommendation = state.get(sessionID)?.recommendation; + const effortInstruction = config.autoSelect + ? `Reasoning effort is selected automatically; call \`${config.toolName}\` only to override it.` + : recommendation && recommendation !== variant + ? `This turn suggests ${recommendation} effort${variant ? ` (current: ${variant})` : ""}. Call \`${config.toolName}\` with \`${recommendation}\` before proceeding.` + : recommendation + ? `The current level fits this turn; do not call \`${config.toolName}\`.` + : `Call \`${config.toolName}\` only when the current level no longer fits the task.`; + const guidance = [ config.systemPrompt.trim(), variant ? `Current reasoning effort level: ${variant}.` : "", `Valid reasoning effort levels for this session: ${variants.join(", ")}.`, - `To change your reasoning effort, use the \`${config.toolName}\` tool with one of the valid levels.`, - "Only call it when the task complexity justifies changing levels.", + effortInstruction, ] .filter(Boolean) .join(" "); From a71910c9bcfebba435874710f3ef50d0476aef90 Mon Sep 17 00:00:00 2001 From: BaconDroid <11067111+BaconDroid@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:10:51 -0400 Subject: [PATCH 6/6] fix: keep the effort recommendation scoped to the turn Clear the recommendation on session idle, keep mid-turn reassessment in the default guidance, and honor explicit variants as an override only in autoSelect. Raise the changeset to minor for the default prompt change. --- .changeset/effort-recommendation.md | 4 ++-- README.md | 2 +- src/config.ts | 2 +- src/index.test.ts | 23 ++++++++++++++++++++++- src/index.ts | 15 ++++++++++++--- 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.changeset/effort-recommendation.md b/.changeset/effort-recommendation.md index 81ebe97..ddf8898 100644 --- a/.changeset/effort-recommendation.md +++ b/.changeset/effort-recommendation.md @@ -1,5 +1,5 @@ --- -"opencode-adaptive-thinking": patch +"opencode-adaptive-thinking": minor --- -Recommend a reasoning effort for each user turn and tell the agent when not to change it. The default guidance now relies on that recommendation instead of a blanket per-turn reassessment instruction. +Recommend a reasoning effort for each user turn and tell the agent when not to change it. The default guidance now relies on that recommendation and keeps a mid-turn reassessment clause. diff --git a/README.md b/README.md index 29aee54..4391f1e 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ The system prompt lists the valid levels for the current session. Cached session With `autoSelect` enabled, the plugin classifies the first user request in a session and sets the effort from the available variants for the current model, ignoring `none`. The chosen level is carried across turns until the agent changes it, the model changes, or a request carries an explicit variant. An explicit variant (UI picker, CLI flag, or API field) is honored and becomes the level carried forward. -When `autoSelect` is off, the plugin still classifies each user turn and adds a recommendation to the guidance. If the recommended level differs from the current one, the agent is told to call the tool with that level; if it matches, the agent is told not to call. The default guidance relies on this recommendation instead of a blanket reassessment instruction. +When `autoSelect` is off, the plugin still classifies each user turn and adds a recommendation to the guidance. If the recommended level differs from the current one, the agent is told to call the tool with that level; if it matches, the agent is told to keep the level unless the work grows beyond it. The default guidance relies on this recommendation instead of a blanket reassessment instruction. Adaptive-thinking guidance is merged into the first existing system prompt instead of a separate system message, so strict chat templates that allow only one system message keep working. diff --git a/src/config.ts b/src/config.ts index 9c367dc..d4dd68c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ export const defaultSystemPrompt = "Match reasoning effort to the work: lower it for routine or trivial turns, raise it for ambiguity, debugging, risky changes, or multi-step synthesis. " + - "Use the per-turn recommendation when one is provided; otherwise call the tool only when the current level no longer fits the task."; + "Use the per-turn recommendation when one is provided, and reassess when new evidence changes the work; otherwise call the tool only when the current level no longer fits the task."; export type Config = { enabled: boolean; diff --git a/src/index.test.ts b/src/index.test.ts index 65be64e..ccf9a5d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -197,7 +197,28 @@ describe("AdaptiveThinkingPlugin", () => { }); const next: Array = []; await transform(plugin, "recommend", next); - expect(next[0]).toContain("do not call `set_reasoning_effort`"); + expect(next[0]).toContain("only if the work grows beyond it"); + }); + + test("clears the recommendation at the end of a turn", async () => { + const { client } = createClient([message("low")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); + const input = { + sessionID: "recommend-idle", + model: { providerID: "provider", modelID: "model" }, + }; + + await plugin["chat.message"](input, { + message: message("low").info, + parts: [{ type: "text", text: "debug the flaky race" }], + }); + await idle(plugin, "recommend-idle"); + + const system: Array = []; + await transform(plugin, "recommend-idle", system); + expect(system[0]).toContain( + "Call `set_reasoning_effort` only when the current level no longer fits the task.", + ); }); test("tells the agent that effort is managed when autoSelect is enabled", async () => { diff --git a/src/index.ts b/src/index.ts index 1282e95..a6b26c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -230,7 +230,7 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { const text = promptText(parts); if (text === undefined) return; - if (variant) { + if (config.autoSelect && variant) { (message.model as { variant?: string }).variant = variant; state.update(sessionID, (entry) => { entry.variant = variant; @@ -274,7 +274,16 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { if (event.type !== "session.idle") return; const sessionID = event.properties.sessionID; - const reset = state.get(sessionID)?.reset; + const session = state.get(sessionID); + if (!session) return; + + if (session.recommendation) { + state.update(sessionID, (entry) => { + delete entry.recommendation; + }); + } + + const reset = session.reset; if (!reset) return; const response = await sendVariantPrompt( @@ -328,7 +337,7 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { : recommendation && recommendation !== variant ? `This turn suggests ${recommendation} effort${variant ? ` (current: ${variant})` : ""}. Call \`${config.toolName}\` with \`${recommendation}\` before proceeding.` : recommendation - ? `The current level fits this turn; do not call \`${config.toolName}\`.` + ? `The current level fits this request; call \`${config.toolName}\` only if the work grows beyond it.` : `Call \`${config.toolName}\` only when the current level no longer fits the task.`; const guidance = [