mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent): preserve thinking levels across model switches
This commit is contained in:
parent
1b347794e2
commit
f255c75071
4 changed files with 324 additions and 11 deletions
|
|
@ -11,7 +11,7 @@ This reference lists user-configurable settings, their types, defaults, and purp
|
|||
| `defaultProvider` | string | Automatic | Startup AI provider. |
|
||||
| `defaultModel` | string | Automatic | Startup model ID. |
|
||||
| `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"medium"` | Startup thinking level. |
|
||||
| `modelThinkingLevels` | object | None | Per-model startup thinking levels keyed by exact `provider/modelId`. |
|
||||
| `modelThinkingLevels` | object | None | Per-model thinking defaults applied at startup and on model switches, keyed by exact `provider/modelId`. Configure from `/settings` → Default thinking level per model or edit manually. |
|
||||
| `thinkingBudgets` | object | Built-in budgets | Token budgets for `minimal`, `low`, `medium`, and `high` thinking levels. |
|
||||
| `enabledModels` | `string[]` | All available models | Model patterns used for startup selection and model cycling. Uses the same format as `--models`. |
|
||||
| `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in the transcript. |
|
||||
|
|
@ -20,6 +20,14 @@ This reference lists user-configurable settings, their types, defaults, and purp
|
|||
|
||||
Cache warming runs only when the model declares a cache lifetime and Pi estimates at least $0.05 in avoided cache-miss cost. Refresh usage counts toward session totals but does not enter model context. `/session` shows the next decision; extensions can override it with `cache_warming_decision`. See [Prompt Cache Lifetimes](models.md#prompt-cache-lifetimes).
|
||||
|
||||
### Thinking level on model switches
|
||||
|
||||
Model switches keep your current thinking level. A target model's `modelThinkingLevels` entry overrides it; when cycling, an explicit `:<thinking>` suffix in `--models` or `enabledModels` has highest priority.
|
||||
|
||||
Unsupported levels are temporarily adjusted to a supported level and restored on a capable model: `high` → non-thinking model (`off`) → thinking model (`high`). Choosing a different level or applying a model override sets the level carried into later switches. Selecting the displayed level leaves it unchanged.
|
||||
|
||||
Reopening or forking a session carries forward the thinking level saved at that point.
|
||||
|
||||
See [Choose a Model](models.md) for model selection and thinking controls.
|
||||
|
||||
## Interaction
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ import {
|
|||
prepareCompaction,
|
||||
shouldCompact,
|
||||
} from "./compaction/index.ts";
|
||||
import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts";
|
||||
import { THINKING_LEVEL_OPTIONS } from "./defaults.ts";
|
||||
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts";
|
||||
import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts";
|
||||
import {
|
||||
|
|
@ -360,6 +360,8 @@ export class AgentSession {
|
|||
readonly settingsManager: SettingsManager;
|
||||
|
||||
private _scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
|
||||
/** Thinking level to restore after a capability clamp; not persisted. */
|
||||
private _thinkingLevelBeforeClamp: ThinkingLevel | undefined;
|
||||
|
||||
// Event subscription state
|
||||
private _unsubscribeAgent?: () => void;
|
||||
|
|
@ -2404,7 +2406,7 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
// Apply thinking level for the new model.
|
||||
// Per-model thinking level overrides take priority over the global default.
|
||||
// Per-model thinking level overrides take priority over the cached pre-clamp or current level.
|
||||
// Model persistence does not implicitly rewrite the global thinking default.
|
||||
this.setThinkingLevel(thinkingLevel);
|
||||
|
||||
|
|
@ -2471,8 +2473,8 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
// Apply thinking level for the new model.
|
||||
// - Explicit scoped model thinking level overrides defaults
|
||||
// - Per-model thinking level overrides take priority over the global default
|
||||
// - Explicit scoped model thinking level overrides per-model defaults
|
||||
// - Per-model thinking level overrides take priority over the cached pre-clamp or current level
|
||||
// setThinkingLevel clamps to model capabilities.
|
||||
// Model persistence does not implicitly rewrite the global thinking default.
|
||||
this.setThinkingLevel(thinkingLevel);
|
||||
|
|
@ -2522,7 +2524,7 @@ export class AgentSession {
|
|||
* Set thinking level.
|
||||
* Clamps to model capabilities based on available thinking levels.
|
||||
* Saves the clamped level to the session transcript only if the level actually changes.
|
||||
* Persists the requested level to global defaults only when options.persist is true.
|
||||
* Persists the supplied level to global defaults only when options.persist is true.
|
||||
*/
|
||||
setThinkingLevel(level: ThinkingLevel, options: ModelMutationOptions = {}): void {
|
||||
const availableLevels = this.getAvailableThinkingLevels();
|
||||
|
|
@ -2532,6 +2534,10 @@ export class AgentSession {
|
|||
const previousLevel = this.agent.state.thinkingLevel;
|
||||
const isChanging = effectiveLevel !== previousLevel;
|
||||
|
||||
// Remember only values lost to clamping. Reapplying the current level is a no-op.
|
||||
if (isChanging || effectiveLevel !== level) {
|
||||
this._thinkingLevelBeforeClamp = effectiveLevel !== level ? level : undefined;
|
||||
}
|
||||
this.agent.state.thinkingLevel = effectiveLevel;
|
||||
|
||||
if (options.persist) {
|
||||
|
|
@ -2583,16 +2589,19 @@ export class AgentSession {
|
|||
|
||||
private _getThinkingLevelForModelSwitch(targetModel?: Model<any>, explicitLevel?: ThinkingLevel): ThinkingLevel {
|
||||
if (explicitLevel !== undefined) {
|
||||
// Overrides replace the inherited level, including any pending clamp restoration.
|
||||
this._thinkingLevelBeforeClamp = undefined;
|
||||
return explicitLevel;
|
||||
}
|
||||
// Per-model default takes priority when switching to a model that has one
|
||||
if (targetModel) {
|
||||
const perModel = this.settingsManager.getModelThinkingLevel(targetModel.provider, targetModel.id);
|
||||
if (perModel !== undefined) {
|
||||
this._thinkingLevelBeforeClamp = undefined;
|
||||
return perModel;
|
||||
}
|
||||
}
|
||||
return this.settingsManager.getDefaultThinkingLevel() ?? this.thinkingLevel ?? DEFAULT_THINKING_LEVEL;
|
||||
return this._thinkingLevelBeforeClamp ?? this.thinkingLevel;
|
||||
}
|
||||
|
||||
private _clampThinkingLevel(level: ThinkingLevel, _availableLevels: ThinkingLevel[]): ThinkingLevel {
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ describe("AgentSession model and extension characterization", () => {
|
|||
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("low");
|
||||
});
|
||||
|
||||
it("applies per-model thinking level override on model switch", async () => {
|
||||
it("inherits the current level after leaving a model with a thinking override", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", name: "One", reasoning: true },
|
||||
|
|
@ -124,7 +124,7 @@ describe("AgentSession model and extension characterization", () => {
|
|||
// Set a per-model override for faux-2
|
||||
harness.settingsManager.setModelThinkingLevel("faux", "faux-2", "low");
|
||||
|
||||
// Session starts on faux-1 with default thinking
|
||||
// Manually select high on faux-1; the global startup default remains medium.
|
||||
harness.session.setThinkingLevel("high");
|
||||
expect(harness.session.thinkingLevel).toBe("high");
|
||||
|
||||
|
|
@ -133,10 +133,11 @@ describe("AgentSession model and extension characterization", () => {
|
|||
await harness.session.setModel(model2);
|
||||
expect(harness.session.thinkingLevel).toBe("low");
|
||||
|
||||
// Switch back to faux-1 → no per-model override, uses global default
|
||||
// faux-1 has no override, so it inherits faux-2's low, not the earlier high or startup medium.
|
||||
const model1 = harness.getModel("faux-1")!;
|
||||
await harness.session.setModel(model1);
|
||||
expect(harness.session.thinkingLevel).toBe("medium");
|
||||
expect(harness.session.thinkingLevel).toBe("low");
|
||||
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("medium");
|
||||
});
|
||||
|
||||
it("falls back to current session thinking level when no per-model or global default is configured", async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,295 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ExtensionAPI } from "../../src/core/extensions/types.ts";
|
||||
import { createAgentSession } from "../../src/core/sdk.ts";
|
||||
import { SessionManager } from "../../src/core/session-manager.ts";
|
||||
import { createTestResourceLoader } from "../utilities.ts";
|
||||
import { createHarness, type Harness } from "./harness.ts";
|
||||
|
||||
describe("thinking level on model switches", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) harnesses.pop()?.cleanup();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ method: "set", reasoning: true },
|
||||
{ method: "set", reasoning: false },
|
||||
{ method: "cycle", reasoning: true },
|
||||
{ method: "cycle", reasoning: false },
|
||||
{ method: "scoped", reasoning: true },
|
||||
{ method: "scoped", reasoning: false },
|
||||
])("retains the level with $method and target reasoning=$reasoning", async ({ method, reasoning }) => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", reasoning: true },
|
||||
{ id: "faux-2", reasoning },
|
||||
],
|
||||
settings: { defaultThinkingLevel: "low" },
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const { session } = harness;
|
||||
const first = harness.getModel("faux-1")!;
|
||||
const second = harness.getModel("faux-2")!;
|
||||
if (method === "scoped") session.setScopedModels([{ model: first }, { model: second }]);
|
||||
|
||||
session.setThinkingLevel("high");
|
||||
if (method === "set") await session.setModel(second);
|
||||
else await session.cycleModel("forward");
|
||||
expect(session.model?.id).toBe(second.id);
|
||||
expect(session.thinkingLevel).toBe(reasoning ? "high" : "off");
|
||||
|
||||
if (method === "set") await session.setModel(first);
|
||||
else await session.cycleModel("backward");
|
||||
expect(session.model?.id).toBe(first.id);
|
||||
expect(session.thinkingLevel).toBe("high");
|
||||
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("low");
|
||||
|
||||
const expectedLevels = reasoning ? ["high"] : ["high", "off", "high"];
|
||||
expect(harness.eventsOfType("thinking_level_changed").map((event) => event.level)).toEqual(expectedLevels);
|
||||
expect(
|
||||
harness.sessionManager
|
||||
.getEntries()
|
||||
.filter((entry) => entry.type === "thinking_level_change")
|
||||
.map((entry) => entry.thinkingLevel),
|
||||
).toEqual(expectedLevels);
|
||||
});
|
||||
|
||||
it("keeps the original value through multiple capability clamps", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", reasoning: true },
|
||||
{ id: "faux-2", reasoning: false },
|
||||
{ id: "faux-3", reasoning: true },
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const { session } = harness;
|
||||
const first = harness.getModel()!;
|
||||
first.thinkingLevelMap = { max: "max" };
|
||||
session.setThinkingLevel("max");
|
||||
|
||||
await session.setModel(harness.getModel("faux-2")!);
|
||||
expect(session.thinkingLevel).toBe("off");
|
||||
await session.setModel(harness.getModel("faux-3")!);
|
||||
expect(session.thinkingLevel).toBe("high");
|
||||
await session.setModel(harness.getModel("faux-2")!);
|
||||
expect(session.thinkingLevel).toBe("off");
|
||||
await session.setModel(first);
|
||||
expect(session.thinkingLevel).toBe("max");
|
||||
expect(Reflect.get(session, "_thinkingLevelBeforeClamp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([false, true])("remembers a clamped setter value with persist=%s", async (persist) => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", reasoning: true },
|
||||
{ id: "faux-2", reasoning: true },
|
||||
],
|
||||
settings: { defaultThinkingLevel: "low" },
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const { session } = harness;
|
||||
const second = harness.getModel("faux-2")!;
|
||||
second.thinkingLevelMap = { max: "max" };
|
||||
|
||||
session.setThinkingLevel("high");
|
||||
session.setThinkingLevel("max", { persist });
|
||||
expect(session.thinkingLevel).toBe("high");
|
||||
expect(harness.eventsOfType("thinking_level_changed")).toHaveLength(1);
|
||||
await session.setModel(second);
|
||||
expect(session.thinkingLevel).toBe("max");
|
||||
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe(persist ? "max" : "low");
|
||||
});
|
||||
|
||||
it.each(["set", "cycle"])(
|
||||
"discards the cached value after a different supported selection via %s",
|
||||
async (method) => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", reasoning: true },
|
||||
{ id: "faux-2", reasoning: true },
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const { session } = harness;
|
||||
const first = harness.getModel()!;
|
||||
first.thinkingLevelMap = { max: "max" };
|
||||
session.setThinkingLevel("max");
|
||||
await session.setModel(harness.getModel("faux-2")!);
|
||||
expect(session.thinkingLevel).toBe("high");
|
||||
|
||||
if (method === "set") session.setThinkingLevel("low");
|
||||
else expect(session.cycleThinkingLevel()).toBe("off");
|
||||
await session.setModel(first);
|
||||
expect(session.thinkingLevel).toBe(method === "set" ? "low" : "off");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ source: "session", persist: false },
|
||||
{ source: "session", persist: true },
|
||||
{ source: "extension", persist: false },
|
||||
])("same-value $source setter preserves the clamp cache with persist=$persist", async ({ source, persist }) => {
|
||||
let extensionApi: ExtensionAPI | undefined;
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", reasoning: true },
|
||||
{ id: "faux-2", reasoning: false },
|
||||
],
|
||||
settings: { defaultThinkingLevel: "medium" },
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
extensionApi = pi;
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const { session } = harness;
|
||||
session.setThinkingLevel("high");
|
||||
await session.setModel(harness.getModel("faux-2")!);
|
||||
if (source === "session") session.setThinkingLevel("off", { persist });
|
||||
else {
|
||||
if (!extensionApi) throw new Error("Expected extension API");
|
||||
extensionApi.setThinkingLevel("off");
|
||||
}
|
||||
expect(harness.eventsOfType("thinking_level_changed")).toHaveLength(2);
|
||||
await session.setModel(harness.getModel("faux-1")!);
|
||||
expect(session.thinkingLevel).toBe("high");
|
||||
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe(persist ? "off" : "medium");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ source: "per-model", level: "low" },
|
||||
{ source: "per-model", level: "off" },
|
||||
{ source: "scoped", level: "low" },
|
||||
{ source: "scoped", level: "off" },
|
||||
] as const)("$source override $level replaces the cached level", async ({ source, level }) => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", reasoning: true },
|
||||
{ id: "faux-2", reasoning: false },
|
||||
{ id: "faux-3", reasoning: true },
|
||||
],
|
||||
settings: {
|
||||
defaultThinkingLevel: "medium",
|
||||
modelThinkingLevels: { "faux/faux-3": source === "scoped" ? "minimal" : level },
|
||||
},
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const { session } = harness;
|
||||
const first = harness.getModel()!;
|
||||
const second = harness.getModel("faux-2")!;
|
||||
const third = harness.getModel("faux-3")!;
|
||||
if (source === "scoped") {
|
||||
session.setScopedModels([{ model: first }, { model: second }, { model: third, thinkingLevel: level }]);
|
||||
}
|
||||
session.setThinkingLevel("high");
|
||||
await session.setModel(second);
|
||||
expect(session.thinkingLevel).toBe("off");
|
||||
if (source === "scoped") await session.cycleModel();
|
||||
else await session.setModel(third);
|
||||
expect(session.thinkingLevel).toBe(level);
|
||||
await session.setModel(first);
|
||||
expect(session.thinkingLevel).toBe(level);
|
||||
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("medium");
|
||||
});
|
||||
|
||||
it("caches the model override itself when that override is clamped", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", reasoning: true },
|
||||
{ id: "faux-2", reasoning: false },
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const { session } = harness;
|
||||
const second = harness.getModel("faux-2")!;
|
||||
session.setThinkingLevel("high");
|
||||
await session.setModel(second);
|
||||
harness.settingsManager.setModelThinkingLevel("faux", second.id, "low");
|
||||
await session.setModel(second);
|
||||
expect(session.thinkingLevel).toBe("off");
|
||||
await session.setModel(harness.getModel()!);
|
||||
expect(session.thinkingLevel).toBe("low");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ source: "explicit", reasoning: false },
|
||||
{ source: "explicit", reasoning: true },
|
||||
{ source: "global", reasoning: false },
|
||||
{ source: "global", reasoning: true },
|
||||
{ source: "per-model", reasoning: false },
|
||||
{ source: "per-model", reasoning: true },
|
||||
{ source: "built-in", reasoning: false },
|
||||
{ source: "built-in", reasoning: true },
|
||||
])("inherits the effective $source startup level with reasoning=$reasoning", async ({ source, reasoning }) => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", reasoning },
|
||||
{ id: "faux-2", reasoning: true },
|
||||
],
|
||||
settings: {
|
||||
defaultThinkingLevel: source === "built-in" ? undefined : "max",
|
||||
modelThinkingLevels: source === "per-model" ? { "faux/faux-1": "low" } : undefined,
|
||||
},
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const second = harness.getModel("faux-2")!;
|
||||
second.thinkingLevelMap = { max: "max" };
|
||||
const { session } = await createAgentSession({
|
||||
cwd: harness.tempDir,
|
||||
model: harness.getModel(),
|
||||
thinkingLevel: source === "explicit" ? "minimal" : undefined,
|
||||
modelRuntime: harness.session.modelRuntime,
|
||||
settingsManager: harness.settingsManager,
|
||||
sessionManager: SessionManager.inMemory(harness.tempDir),
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
try {
|
||||
const expected = !reasoning
|
||||
? "off"
|
||||
: source === "explicit"
|
||||
? "minimal"
|
||||
: source === "per-model"
|
||||
? "low"
|
||||
: source === "global"
|
||||
? "high"
|
||||
: "medium";
|
||||
expect(session.thinkingLevel).toBe(expected);
|
||||
await session.setModel(second);
|
||||
expect(session.thinkingLevel).toBe(expected);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not restore the runtime clamp cache when reopening a session", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", reasoning: true },
|
||||
{ id: "faux-2", reasoning: false },
|
||||
],
|
||||
settings: { defaultThinkingLevel: "high" },
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.session.setThinkingLevel("high");
|
||||
await harness.session.setModel(harness.getModel("faux-2")!);
|
||||
harness.sessionManager.appendMessage({ role: "user", content: "hello", timestamp: Date.now() });
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: harness.tempDir,
|
||||
modelRuntime: harness.session.modelRuntime,
|
||||
settingsManager: harness.settingsManager,
|
||||
sessionManager: harness.sessionManager,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
try {
|
||||
expect(session.thinkingLevel).toBe("off");
|
||||
await session.setModel(harness.getModel("faux-1")!);
|
||||
expect(session.thinkingLevel).toBe("off");
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue