diff --git a/.changeset/llm-classifier.md b/.changeset/llm-classifier.md new file mode 100644 index 0000000..bb3c108 --- /dev/null +++ b/.changeset/llm-classifier.md @@ -0,0 +1,5 @@ +--- +"opencode-adaptive-thinking": minor +--- + +Add optional `afterIdle` / `afterUserMessage` triggers for the guidance and an optional LLM classifier. The classifier uses the same guidance, the last compaction summary or the current segment as context, and the current message last. Session state is bounded at 5,000 entries, expires after seven days, and is removed when a session is deleted. 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..444b988 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,34 @@ The plugin accepts these optional settings: - `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. +- `triggers` (optional): when to inject the guidance and run the classifier. Fields: `afterIdle` and `afterUserMessage` (booleans). Omit `triggers` for the original behavior (guidance on every request); both `false` disables the guidance. +- `classifier` (optional): an LLM classifier that chooses the effort on real user messages. Disabled by default. Fields: `enabled` (boolean) and `model` (`{ providerID, modelID }`, default `opencode/big-pickle`). + +### Triggers and classifier + +`triggers` scopes when the guidance is injected: + +- `afterIdle`: first request after an idle or session start, when no user message is pending. +- `afterUserMessage`: first request after a real user message. + +Both are reset on `session.idle`. Omit `triggers` for the original behavior (guidance on every request). Both `false` disables the guidance. + +When `classifier.enabled` is true, the plugin asks the configured model for one valid effort level on real user messages. It uses the same guidance as its system prompt, then the last compaction summary (assistant message with `summary: true`) or the messages since session start/last idle, then the current message last. The call uses a temporary child session with tools disabled and a 5 s timeout; failures leave the level unchanged. The main session still receives the guidance at the triggers. + +```json +"triggers": { + "afterIdle": true, + "afterUserMessage": true +}, +"classifier": { + "enabled": true, + "model": { "providerID": "opencode", "modelID": "big-pickle" } +} +``` + +Synthetic and ignored messages are skipped. The classifier sends the user's request text and session context to the configured model; keep it disabled if that is not acceptable. + +Session state is kept in a 5,000-entry LRU and expires after seven days of inactivity; deleted sessions are removed. ### Example @@ -89,6 +117,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..c4aba11 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,26 +1,107 @@ -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 ClassifierConfig = { + enabled: boolean; + model: { providerID: string; modelID: string }; +}; + +export type TriggersConfig = { + afterIdle: boolean; + afterUserMessage: boolean; +}; + +export type Config = { + enabled: boolean; + quiet: boolean; + toolName: string; + toolDescription: string; + systemPrompt: string; + triggers?: TriggersConfig; + classifier: ClassifierConfig; +}; + +const defaults: Config = { enabled: true, quiet: false, toolName: "set_reasoning_effort", toolDescription: "Set your reasoning effort", systemPrompt: defaultSystemPrompt, + classifier: { + enabled: false, + model: { providerID: "opencode", modelID: "big-pickle" }, + }, }; -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); +const cloneDefaults = (): Config => ({ + ...defaults, + classifier: { ...defaults.classifier, model: { ...defaults.classifier.model } }, +}); + +export const parseConfig = (options: unknown): { config: Config } | { error: string } => { + if (options === undefined) return { config: cloneDefaults() }; + if (typeof options !== "object" || options === null || Array.isArray(options)) { + return { error: "options must be an object" }; + } + + const input = options as Record; + const config = cloneDefaults(); + + 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; + } + + const triggers = input.triggers; + if (triggers !== undefined) { + if (typeof triggers !== "object" || triggers === null || Array.isArray(triggers)) { + return { error: "triggers must be an object" }; + } + const value = triggers as Record; + const afterIdle = value.afterIdle === undefined ? false : value.afterIdle; + const afterUserMessage = value.afterUserMessage === undefined ? false : value.afterUserMessage; + if (typeof afterIdle !== "boolean" || typeof afterUserMessage !== "boolean") { + return { error: "triggers.afterIdle and triggers.afterUserMessage must be booleans" }; + } + config.triggers = { afterIdle, afterUserMessage }; + } + + const classifier = input.classifier; + if (classifier !== undefined) { + if (typeof classifier !== "object" || classifier === null || Array.isArray(classifier)) { + return { error: "classifier must be an object" }; + } + const value = classifier as Record; + + if (value.enabled !== undefined) { + if (typeof value.enabled !== "boolean") + return { error: "classifier.enabled must be a boolean" }; + config.classifier.enabled = value.enabled; + } + + if (value.model !== undefined) { + if (typeof value.model !== "object" || value.model === null || Array.isArray(value.model)) { + return { error: "classifier.model must be an object" }; + } + const model = value.model as Record; + if (typeof model.providerID !== "string" || typeof model.modelID !== "string") { + return { error: "classifier.model must have string providerID and modelID" }; + } + config.classifier.model = { providerID: model.providerID, modelID: model.modelID }; + } + } + + return { config }; +}; diff --git a/src/index.test.ts b/src/index.test.ts index 991f46a..78214be 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,418 +1,550 @@ 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 variants = { none: {}, low: {}, medium: {}, high: {}, xhigh: {} }; -const createMessage = (variant = "medium") => ({ +const message = (variant?: string, agent = "agent") => ({ info: { - id: "message", role: "user", - agent: "agent", - model: { - providerID: "provider", - modelID: "model", - variant, - }, + agent, + model: { providerID: "provider", modelID: "model", ...(variant ? { variant } : {}) }, }, parts: [], }); -const createAgentMessage = (agent: string) => ({ +const assistant = (variant?: string) => ({ info: { - id: `message-${agent}`, - role: "user", - agent, + role: "assistant", + agent: "agent", + 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 }; + }), + create: vi.fn(async () => ({ data: { id: "classifier" }, error: undefined })), + prompt: vi.fn(async () => ({ + data: { parts: [{ type: "text", text: "high" }] }, + error: undefined, + })), + delete: vi.fn(async () => ({ data: true, 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 }); + +const set = (plugin: any, level: string, persist: boolean, sessionID: string) => + plugin.tool.set_reasoning_effort.execute({ level, persist }, ctx(sessionID)); + +const say = async ( + plugin: any, + sessionID: string, + text: string, + extra: Record = {}, +) => { + const user = message().info; + await plugin["chat.message"]( + { sessionID, model: { providerID: "provider", modelID: "model" }, ...extra }, + { message: user, parts: [{ type: "text", text }] }, + ); + return user; +}; - await setReasoningEffort(plugin, { level: "low", persist: false }, toolContext); +const idle = (plugin: any, sessionID: string) => + plugin.event({ event: { type: "session.idle", properties: { sessionID } } }); - return { client, plugin }; -}; +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); + 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.", + }); - await setReasoningEffort(plugin, { level: "high", persist: true }, toolContext); + expect(plugin.tool.adjust_reasoning.description).toBe("Adjust it"); + expect(plugin.tool.set_reasoning_effort).toBeUndefined(); - expect(client.session.promptAsync).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - agent: "custom-worker", - model: { providerID: "provider", modelID: "model" }, - variant: "high", - }), - }), - ); + 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("returns no hooks when disabled", async () => { - const { client } = createClient("disabled-plugin"); - const plugin = await AdaptiveThinkingPlugin({ client } as never, { enabled: false }); - - expect(plugin).toEqual({}); + const { client } = createClient(); + expect(await AdaptiveThinkingPlugin({ client } as never, { enabled: false })).toEqual({}); }); - test("logs configuration errors without creating hooks", async () => { - const { client } = createClient("invalid-config"); - const plugin = await AdaptiveThinkingPlugin({ client } as never, { enabled: "yes" }); - - 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(quiet.client.tui.showToast).not.toHaveBeenCalled(); + }); - 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"), - }), - }); + test("rejects an invalid level without prompting", async () => { + const { client, prompts } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); + + expect(await set(plugin, "extreme", true, "invalid-level")).toContain( + "Invalid reasoning effort level", + ); + expect(prompts).toHaveLength(0); }); - 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", - }); + test("reports models without reasoning variants", async () => { + const { client, prompts } = createClient([message("medium")], {}); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); - await setReasoningEffort(plugin, { level: "low", persist: false }, toolContext); + expect(await set(plugin, "high", true, "no-variants")).toContain( + "no valid reasoning effort levels", + ); + expect(prompts).toHaveLength(0); + }); - expect(client.app.log).toHaveBeenCalledWith({ - body: expect.objectContaining({ - service: "opencode-adaptive-thinking", - }), + 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); + + await set(plugin, "low", false, "temporary"); + await idle(plugin, "temporary"); + + 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 }], }); }); - 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("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); - expect(plugin.tool?.set_reasoning_effort).toBeUndefined(); - expect(plugin.tool?.adjust_reasoning?.description).toBe( - "Adjust reasoning depth for the next response", - ); + await set(plugin, "low", false, "assistant-shape"); + await idle(plugin, "assistant-shape"); - await setReasoningEffort( - plugin, - { level: "high", persist: true }, - toolContext, - "adjust_reasoning", - ); + expect(prompts[0].model).toEqual({ providerID: "provider", modelID: "model" }); + expect(prompts.map((prompt) => prompt.variant)).toEqual(["low", "high"]); + }); - expect(client.session.promptAsync).toHaveBeenCalledWith( - expect.objectContaining({ body: expect.objectContaining({ variant: "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("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.", - }); - const system: string[] = []; + 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", + ]); + expect(client.app.log).toHaveBeenCalled(); + }); - await plugin["experimental.chat.system.transform"]!( - { - sessionID, - model: { variants }, - } as never, - { system }, - ); + 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(system).toHaveLength(1); - expect(system[0]).toContain( - "Prefer the cheapest reasoning level that can safely complete the task.", + expect(await set(plugin, "high", true, "provider-failure")).toContain( + "no valid reasoning effort levels", ); - expect(system[0]).not.toContain("You MUST manage reasoning effort actively"); + expect(prompts).toHaveLength(0); + expect(client.app.log).toHaveBeenCalled(); }); - 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[] = []; + test("does not reset a persisted level on idle", async () => { + const { client, prompts } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never); - await setReasoningEffort(plugin, { level: "high", persist: true }, toolContext); - await plugin["experimental.chat.system.transform"]!( - { - sessionID, - model: { variants: switchedModelVariants }, - } as never, - { system }, + await set(plugin, "high", true, "persisted"); + await idle(plugin, "persisted"); + expect(prompts).toHaveLength(1); + }); + + 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(await set(plugin, "high", true, "fetch-failure")).toContain( + "could not read the session", ); + expect(client.app.log).toHaveBeenCalled(); + }); + + 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"); - expect(system[0]).toContain("Current reasoning effort level: medium."); + 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("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"), - ]); - 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 }, - ); + 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); + const system: Array = []; + await transform(plugin, "agent-fallback", system, {}); expect(system[0]).toContain("Current reasoning effort level: low."); }); - 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); + test("drops session state when the session is deleted", async () => { + const { client, prompts } = createClient([message("medium")]); + const plugin: any = 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 } }, + await set(plugin, "low", false, "deleted-session"); + await plugin.event({ + event: { type: "session.deleted", properties: { info: { id: "deleted-session" } } }, } as never); + await idle(plugin, "deleted-session"); - 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(prompts).toHaveLength(1); + }); + + test("injects guidance once per user turn when afterUserMessage is set", async () => { + const { client } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { + triggers: { afterIdle: false, afterUserMessage: true }, }); + + await say(plugin, "guidance-user", "hello"); + const first = new Array(); + await transform(plugin, "guidance-user", first); + expect(first).toHaveLength(1); + + const second = new Array(); + await transform(plugin, "guidance-user", second); + expect(second).toHaveLength(0); }); - test("evicts oldest session state when the cache is full", async () => { - const oldest = await touchTemporarySession("lru-oldest"); + test("injects guidance after idle when afterIdle is set", async () => { + const { client } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { + triggers: { afterIdle: true, afterUserMessage: false }, + }); - for (let i = 0; i < 500; i++) { - await touchTemporarySession(`lru-session-${i}`); - } + const first = new Array(); + await transform(plugin, "guidance-idle", first); + expect(first).toHaveLength(1); - await oldest.plugin.event!({ - event: { type: "session.idle", properties: { sessionID: "lru-oldest" } }, - } as never); + const second = new Array(); + await transform(plugin, "guidance-idle", second); + expect(second).toHaveLength(0); - expect(oldest.client.session.promptAsync).toHaveBeenCalledTimes(1); + await idle(plugin, "guidance-idle"); + const third = new Array(); + await transform(plugin, "guidance-idle", third); + expect(third).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); + test("does not inject twice for a user message after idle", async () => { + const { client } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { + triggers: { afterIdle: true, afterUserMessage: true }, + }); - await setReasoningEffort(plugin, { level: "high", persist: true }, toolContext); - await plugin.event!({ - event: { type: "session.idle", properties: { sessionID } }, - } as never); + await idle(plugin, "guidance-both"); + await say(plugin, "guidance-both", "hello"); + const system = new Array(); + await transform(plugin, "guidance-both", system); + expect(system).toHaveLength(1); + }); - expect(client.session.promptAsync).toHaveBeenCalledTimes(1); + test("injects nothing when both triggers are false", async () => { + const { client } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { + triggers: { afterIdle: false, afterUserMessage: false }, + }); + + await say(plugin, "guidance-none", "hello"); + const system = new Array(); + await transform(plugin, "guidance-none", system); + expect(system).toHaveLength(0); }); - 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("classifies user turns but not idle turns", async () => { + const { client } = createClient([message("medium")]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { + triggers: { afterIdle: true, afterUserMessage: true }, + classifier: { enabled: true }, + }); - const result = await setReasoningEffort( - plugin, - { level: "extreme", persist: true }, - toolContext, - ); + await transform(plugin, "classifier-idle", new Array()); + expect(client.session.create).not.toHaveBeenCalled(); - expect(result).toContain("Invalid reasoning effort level"); - expect(client.session.promptAsync).not.toHaveBeenCalled(); + await say(plugin, "classifier-user", "debug the flaky test"); + expect(client.session.create).toHaveBeenCalledTimes(1); }); - 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); + test("uses the last compaction summary in the classifier context", async () => { + const summary = "SUMMARY-CONTEXT"; + const { client } = createClient([ + message("medium"), + { + info: { role: "assistant", agent: "agent", summary: true, time: { created: 1 } }, + parts: [{ type: "text", text: summary }], + }, + ]); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { + classifier: { enabled: true }, + }); - const result = await setReasoningEffort(plugin, { level: "high", persist: true }, toolContext); + await say(plugin, "classifier-summary", "next step"); + const calls = client.session.prompt.mock.calls as unknown as Array<[any]>; + const call = calls.at(-1)?.[0]; + expect(call?.body.parts[0].text).toContain(summary); + expect(call?.body.parts[0].text).toContain("Current message:\nnext step"); + }); - 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"), + test("classifies each real user message", async () => { + const { client } = createClient(); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { + classifier: { enabled: true }, + }); + const input = { + sessionID: "classifier-initial", + model: { providerID: "provider", modelID: "model" }, + }; + + const first = message().info; + await plugin["chat.message"](input, { + message: first, + parts: [{ type: "text", text: "debug the flaky auth test" }], + }); + const second = message().info; + await plugin["chat.message"](input, { + message: second, + parts: [{ type: "text", text: "explain this function" }], + }); + + expect((first.model as { variant?: string }).variant).toBe("high"); + expect((second.model as { variant?: string }).variant).toBe("high"); + expect(client.session.create).toHaveBeenCalledTimes(2); + expect(client.session.create).toHaveBeenCalledWith({ + body: expect.objectContaining({ parentID: "classifier-initial" }), + }); + expect(client.session.prompt).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + model: { providerID: "opencode", modelID: "big-pickle" }, + tools: { "*": false }, + }), }), + ); + expect(client.session.delete).toHaveBeenCalled(); + }); + + test("does not classify when disabled or with an explicit variant", async () => { + const { client } = createClient(); + const off: any = await AdaptiveThinkingPlugin({ client } as never); + await off["chat.message"]( + { sessionID: "classifier-off", model: { providerID: "provider", modelID: "model" } }, + { message: message().info, parts: [{ type: "text", text: "debug" }] }, + ); + expect(client.session.create).not.toHaveBeenCalled(); + + const on: any = await AdaptiveThinkingPlugin({ client } as never, { + classifier: { enabled: true }, }); + const first = message().info; + await on["chat.message"]( + { + sessionID: "classifier-explicit", + model: { providerID: "provider", modelID: "model" }, + variant: "low", + }, + { message: first, parts: [{ type: "text", text: "debug" }] }, + ); + expect(client.session.create).not.toHaveBeenCalled(); + expect((first.model as { variant?: string }).variant).toBeUndefined(); }); - 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 } }, + test("ignores synthetic user messages", async () => { + const { client } = createClient(); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { + classifier: { enabled: true }, + }); + const first = message().info; + + await plugin["chat.message"]( + { sessionID: "classifier-synthetic", model: { providerID: "provider", modelID: "model" } }, + { + message: first, + parts: [{ type: "text", text: "Continue if you have next steps.", synthetic: true }], + }, + ); + + expect(client.session.create).not.toHaveBeenCalled(); + expect((first.model as { variant?: string }).variant).toBeUndefined(); + }); + + test("ignores an invalid classifier answer", async () => { + const { client } = createClient(); + client.session.prompt.mockResolvedValueOnce({ + data: { parts: [{ type: "text", text: "unsure" }] }, + error: undefined, } as never); - await plugin.event!({ - event: { type: "session.idle", properties: { sessionID } }, + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { + classifier: { enabled: true }, + }); + const first = message().info; + + await plugin["chat.message"]( + { sessionID: "classifier-invalid", model: { providerID: "provider", modelID: "model" } }, + { message: first, parts: [{ type: "text", text: "debug" }] }, + ); + + expect((first.model as { variant?: string }).variant).toBeUndefined(); + }); + + test("fails open when the classifier cannot be created", async () => { + const { client } = createClient(); + client.session.create.mockResolvedValueOnce({ + data: undefined, + error: { data: { message: "unavailable" } }, } as never); + const plugin: any = await AdaptiveThinkingPlugin({ client } as never, { + classifier: { enabled: true }, + }); + const first = message().info; - expect(client.session.promptAsync).toHaveBeenCalledTimes(3); + await plugin["chat.message"]( + { sessionID: "classifier-failure", model: { providerID: "provider", modelID: "model" } }, + { message: first, parts: [{ type: "text", text: "debug" }] }, + ); + + expect((first.model as { variant?: string }).variant).toBeUndefined(); expect(client.app.log).toHaveBeenCalledWith({ - body: expect.objectContaining({ - level: "error", - message: expect.stringContaining("Failed to reset reasoning effort"), - }), + body: expect.objectContaining({ level: "warn" }), }); }); + + test("rejects an invalid classifier configuration", async () => { + const { client } = createClient(); + const plugin = await AdaptiveThinkingPlugin({ client } as never, { + classifier: { enabled: "yes" }, + }); + + expect(plugin).toEqual({}); + expect(client.app.log).toHaveBeenCalled(); + }); }); diff --git a/src/index.ts b/src/index.ts index 3b21c89..66f2336 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,24 @@ 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, Provider } 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 maxSessionStateSize = 5000; +const stateTtlMs = 7 * 24 * 60 * 60 * 1000; +const historyLimit = 20; +const classifierTimeout = 5000; +type ModelInfo = { providerID: string; modelID: string }; +type SessionMessage = { info: Message; parts: Array }; type SessionState = { - currentVariant?: string; - persistedVariant?: string; - temporaryResetVariant?: string; - temporaryResetAgent?: string; - temporaryResetModel?: { providerID: string; modelID: string }; + variant?: string; + idlePending?: boolean; + userPending?: boolean; + lastIdleAt?: number; + touchedAt?: number; + reset?: { level: string; agent: string; model?: ModelInfo }; }; class SessionStateCache { @@ -24,38 +30,53 @@ class SessionStateCache { const entry = this.entries.get(sessionID); if (!entry) return; + if (entry.touchedAt && Date.now() - entry.touchedAt > stateTtlMs) { + this.entries.delete(sessionID); + return; + } + + entry.touchedAt = Date.now(); this.entries.delete(sessionID); this.entries.set(sessionID, entry); return entry; } + delete(sessionID: string) { + this.entries.delete(sessionID); + } + update(sessionID: string, update: (entry: SessionState) => void) { const entry = this.get(sessionID) ?? {}; update(entry); + entry.touchedAt = Date.now(); 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 +84,242 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { }, }); } + logError("Invalid Adaptive Thinking plugin configuration", parsed.error); + return {}; + } + + const config = parsed.config; + if (!config.enabled) return {}; + + const classifierSessions = new Set(); + let classifierWarned = false; + + const warnClassifier = (message: string, error?: unknown) => { + if (classifierWarned) return; + classifierWarned = true; client.app.log({ body: { service: serviceName, - level: "error", - message: `Invalid Adaptive Thinking plugin configuration: ${configParseResult.error.message}`, + level: "warn", + message: error === undefined ? message : `${message}: ${JSON.stringify(error)}`, }, }); - return {}; - } + }; - const config = configParseResult.data; - if (!config.enabled) return {}; + const promptText = (parts: Array) => { + if (parts.some((part) => part.type === "text" && (part.ignored || part.synthetic))) return; + const text = parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + .trim(); + return text.length > 0 ? text : undefined; + }; - 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; } + const provider = providers.find((candidate) => candidate.id === model.providerID); + return getModelVariants(provider?.models[model.modelID]); + }; - return client.session.promptAsync({ + const fetchMessages = async (sessionID: string) => { + const response = await client.session.messages({ path: { id: sessionID }, - body, + query: { limit: historyLimit }, }); + if (response.error) { + logError(`Failed to retrieve messages for session ${sessionID}`, response.error); + return; + } + return (response.data ?? []) as Array; }; - const resolveValidVariants = async (sessionID: string, model?: Model): Promise => { - const inputModelVariants = getModelVariants(model); - if (inputModelVariants.length > 0) return inputModelVariants; + const modelOf = (info: Message): ModelInfo => + "model" in info + ? { providerID: info.model.providerID, modelID: info.model.modelID } + : { providerID: info.providerID, modelID: info.modelID }; - 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 []; + const latestModel = (messages: Array): ModelInfo | undefined => { + const last = messages.at(-1)?.info; + return last ? modelOf(last) : undefined; + }; + + const latestVariant = async (messages: Array) => { + let agent: string | undefined; + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i]!.info; + const variant = "model" in info ? info.model.variant : info.variant; + if (variant) return variant; + agent ??= info.agent; } - const modelInfo = resolveLatestModelInfo(messagesResponse.data); - if (!modelInfo) return []; + if (!agent) 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 []; + const response = await client.app.agents(); + if (response.error) { + logError("Failed to retrieve agents", response.error); + return; } - const provider = providers.data?.all.find((p) => p.id === modelInfo.providerID) as - | Provider + const match = response.data?.find((candidate) => candidate.name === agent) as + | { variant?: string } | undefined; - if (!provider) return []; - - return getModelVariants(provider.models[modelInfo.modelID] as Model | undefined); + return match?.variant; }; - const resolveLatestModelInfo = ( - data: unknown, - ): { providerID: string; modelID: string } | undefined => { - const messages = data as Array<{ info: Message; parts: Array }>; + const messageText = (message: SessionMessage, includeSynthetic = false) => + message.parts + .map((part) => + part.type === "text" && !part.ignored && (includeSynthetic || !part.synthetic) + ? part.text + : "", + ) + .filter(Boolean) + .join("\n") + .trim(); + + const latestSummary = (messages: 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 }; - } + const info = messages[i]!.info; + if (info.role === "assistant" && info.summary === true) { + const text = messageText(messages[i]!, true); + if (text) return text; } } - return; }; - 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 }>; - 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; + const buildContext = async (sessionID: string) => { + const response = await client.session.messages({ path: { id: sessionID } }); + if (response.error) return ""; + const messages = (response.data ?? []) as Array; + const summary = latestSummary(messages); + if (summary) return summary; + const since = state.get(sessionID)?.lastIdleAt ?? 0; + return messages + .filter((message) => !since || message.info.time.created >= since) + .map((message) => { + const text = messageText(message); + return text ? `${message.info.role}: ${text}` : ""; + }) + .filter(Boolean) + .join("\n"); + }; + + const classifyWithModel = async ( + parentID: string, + text: string, + variants: Array, + model: ModelInfo, + current: string | undefined, + context: string, + ) => { + const created = await (async () => { + try { + return await client.session.create({ + body: { parentID, title: "Reasoning effort classifier" }, + }); + } catch { + return undefined; } - agentName ??= message.info.agent; + })(); + if (!created || created.error || !created.data) { + warnClassifier("Failed to create the classifier session", created?.error); + return; } - if (!agentName) return; + const id = created.data.id; + classifierSessions.add(id); + try { + const pending = (async () => { + try { + return await client.session.prompt({ + path: { id }, + body: { + model, + system: [ + config.systemPrompt.trim(), + current ? `Current reasoning effort level: ${current}.` : "", + `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.", + "Classifier task: choose exactly one of the valid levels for the current message and answer with that word only.", + ] + .filter(Boolean) + .join(" "), + tools: { "*": false }, + parts: [ + { + type: "text", + text: `Session context:\n${context || "(none)"}\n\nCurrent message:\n${text}`, + }, + ], + }, + }); + } catch { + return undefined; + } + })(); + const response = await Promise.race([ + pending, + new Promise((resolve) => + setTimeout(() => resolve(undefined), classifierTimeout), + ), + ]); + if (!response || response.error || !response.data) { + warnClassifier("The classifier request failed", response?.error); + 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)}`, - }, - }); - return; + const answer = response.data.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(" ") + .toLowerCase(); + let best: { index: number; level: string } | undefined; + for (const level of variants) { + const escaped = level.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`\\b${escaped}\\b`, "i").exec(answer); + if (match && (!best || match.index < best.index)) best = { index: match.index, level }; + } + return best?.level; + } finally { + classifierSessions.delete(id); + try { + await client.session.delete({ path: { id } }); + } catch {} } - const agents = agentsResponse.data; - const agent = agents?.find((a) => a.name === agentName) as Agent | undefined; - return agent?.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 +339,40 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { ), }, execute: async ({ level, persist }, { sessionID, agent }) => { - const validVariants = await resolveValidVariants(sessionID); - if (validVariants.length === 0) { + if (classifierSessions.has(sessionID)) return "Unavailable in this session."; + 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 ?? + state.get(sessionID)?.variant ?? + (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 } : {}) }; } }); @@ -283,69 +380,133 @@ export const AdaptiveThinkingPlugin: Plugin = async ({ client }, options) => { }, }), }, + "chat.message": async ({ sessionID, variant }, { message, parts }) => { + if (classifierSessions.has(sessionID)) return; + + const text = promptText(parts); + if (text === undefined) return; + + state.update(sessionID, (entry) => { + entry.userPending = true; + }); + + if (!config.classifier.enabled || variant) return; + if (config.triggers !== undefined && !config.triggers.afterUserMessage) return; + + const variants = await variantsFor(message.model); + if (variants.length === 0) return; + + const current = state.get(sessionID)?.variant; + const context = await buildContext(sessionID); + const level = await classifyWithModel( + sessionID, + text, + variants, + config.classifier.model, + current, + context, + ); + if (!level || !variants.includes(level)) return; + + (message.model as { variant?: string }).variant = level; + state.update(sessionID, (entry) => { + entry.variant = level; + }); + }, 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; - } + if (event.type === "session.deleted") { + state.delete(event.properties.info.id); + return; + } + if (event.type !== "session.idle") return; - state.update(sessionID, (entry) => { - entry.currentVariant = resetVariant; - delete entry.temporaryResetVariant; - delete entry.temporaryResetAgent; - delete entry.temporaryResetModel; - }); + const sessionID = event.properties.sessionID; + if (classifierSessions.has(sessionID)) return; + + state.update(sessionID, (entry) => { + entry.userPending = false; + entry.idlePending = true; + entry.lastIdleAt = Date.now(); + }); + + 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; + if (!sessionID || classifierSessions.has(sessionID)) return; - const variants = await resolveValidVariants(sessionID, model as Model); - if (variants.length === 0) return; + const session = state.get(sessionID); + const userPending = session?.userPending === true; + const idlePending = session?.idlePending ?? true; - 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; - }); + let inject: boolean; + if (config.triggers === undefined) inject = true; + else if (userPending) inject = config.triggers.afterUserMessage; + else if (idlePending) inject = config.triggers.afterIdle; + else inject = false; + + if (userPending || idlePending) { + state.update(sessionID, (entry) => { + entry.userPending = false; + entry.idlePending = false; + }); + } + if (!inject) return; + + let variants = getModelVariants(model); + let variant = state.get(sessionID)?.variant; + if (variant && !variants.includes(variant)) variant = undefined; + + 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; }, }; };