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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/llm-classifier.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/quiet-resets.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 0 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 94 additions & 13 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;

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<string, unknown>;
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 };
};
Loading