diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 69c82e889..7f34f27a9 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -416,6 +416,8 @@ See `SessionBeforeTreeEvent` and `TreePreparation` in the types file. ## Settings +See [Settings: Compaction](settings.md#compaction) and [Settings: Branch summaries](settings.md#branch-summaries) for summary model and thinking-level options. + Configure compaction in `~/.pi/agent/settings.json` or `/.pi/settings.json`: ```json diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 3de528c44..37e1fc294 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -74,6 +74,13 @@ CLI tool options override this setting for one invocation. `--tools` with only ` | `compaction.reserveTokens` | number | `16384` | Free context tokens below which automatic compaction starts. | | `compaction.keepRecentTokens` | number | `20000` | Recent tokens retained without summarization. | | `compaction.modelOverrides` | object | None | Per-model token settings keyed by exact `provider/modelId`. | +| `compaction.provider` | string | current session provider | Provider used for compaction summaries. | +| `compaction.model` | string | current session model | Model ID used for compaction summaries. | +| `compaction.thinkingLevel` | string | current session thinking level | Thinking level for compaction summaries: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`. | + +`compaction.provider` and `compaction.model` can be set independently. An omitted component is inherited from the current session model. Explicit model settings must resolve to an existing model; invalid settings produce an error rather than falling back to the session model. + +Choose a summary model whose context window can accommodate the history being summarized. Compaction does not split that history to fit a smaller summary model or automatically fall back to another model. Automatic compaction thresholds still use the session model's context window. If the summary model has a smaller window, increase `compaction.reserveTokens` (or its session-model override) to compact earlier. @@ -87,6 +94,11 @@ See [Compaction Reference](compaction.md) for trigger, summarization, and valida |---|---|---|---| | `branchSummary.reserveTokens` | number | `16384` | Tokens reserved when summarizing branch history. | | `branchSummary.skipPrompt` | boolean | `false` | Skip the branch-summary prompt and default to no summary. | +| `branchSummary.provider` | string | current session provider | Provider used for branch summaries. | +| `branchSummary.model` | string | current session model | Model ID used for branch summaries. | +| `branchSummary.thinkingLevel` | string | current session thinking level | Thinking level for branch summaries: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`. | + +`branchSummary.provider` and `branchSummary.model` can be set independently. An omitted component is inherited from the current session model. Explicit model settings must resolve to an existing model; invalid settings produce an error rather than falling back to the session model. ## Terminal and display diff --git a/packages/coding-agent/schemas/settings.schema.json b/packages/coding-agent/schemas/settings.schema.json index c331ae27a..12e77e5d8 100644 --- a/packages/coding-agent/schemas/settings.schema.json +++ b/packages/coding-agent/schemas/settings.schema.json @@ -109,6 +109,15 @@ "compaction": { "type": "object", "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string" + }, + "thinkingLevel": { + "$ref": "#/$defs/ModelThinkingLevel" + }, "enabled": { "type": "boolean", "default": true @@ -151,6 +160,15 @@ "branchSummary": { "type": "object", "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string" + }, + "thinkingLevel": { + "$ref": "#/$defs/ModelThinkingLevel" + }, "reserveTokens": { "type": "number", "description": "Tokens reserved for the prompt and LLM response.", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index f7dfa4da8..a3b19aa63 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -605,6 +605,7 @@ export class AgentSession { private async _getSummarizationRequestAuth( selectedModel: Model, signal?: AbortSignal, + selectedThinkingLevel: ThinkingLevel = this.thinkingLevel, ): Promise<{ model: Model; apiKey?: string; @@ -616,10 +617,10 @@ export class AgentSession { const { model, thinkingLevel } = isVirtualModel(selectedModel) ? await this._modelRuntime.resolveModel(selectedModel, convertToLlm(this.messages), { reason: "direct", - thinkingLevel: this.thinkingLevel, + thinkingLevel: selectedThinkingLevel, signal, }) - : { model: selectedModel, thinkingLevel: this.thinkingLevel }; + : { model: selectedModel, thinkingLevel: selectedThinkingLevel }; if (this.agent.streamFunction === streamSimple) { return { ...(await this._getRequiredRequestAuth(model, signal)), thinkingLevel }; } @@ -644,6 +645,36 @@ export class AgentSession { } } + private _getSummarizationModel( + settings: { provider?: string; model?: string; thinkingLevel?: ThinkingLevel }, + fallbackModel: Model | undefined, + settingsKey: "compaction" | "branchSummary", + ): { model: Model; thinkingLevel: ThinkingLevel } { + const hasModelOverride = settings.provider !== undefined || settings.model !== undefined; + const provider = settings.provider ?? fallbackModel?.provider; + const modelId = settings.model ?? fallbackModel?.id; + if (!provider || !modelId) { + if (hasModelOverride) { + throw new Error( + `Unable to resolve summarization model. Check ${settingsKey}.provider and ${settingsKey}.model in your settings.`, + ); + } + throw new Error(formatNoModelSelectedMessage()); + } + + const model = hasModelOverride ? this._modelRuntime.getModel(provider, modelId) : fallbackModel; + if (!model) { + throw new Error( + `Summarization model not found: ${provider}/${modelId}. Check ${settingsKey}.provider and ${settingsKey}.model in your settings.`, + ); + } + + return { + model, + thinkingLevel: clampThinkingLevel(model, settings.thinkingLevel ?? this.thinkingLevel), + }; + } + /** * The model whose limits apply to `message`, or undefined when the message came from another * model. Under a virtual selection, that is the physical model that produced it. @@ -2852,13 +2883,15 @@ export class AgentSession { /** Generate Pi's built-in compaction summary for manual and automatic compaction. */ private async _runDefaultCompaction( preparation: CompactionPreparation, - model: Model, + model: Model | undefined, customInstructions: string | undefined, signal: AbortSignal, reason: "manual" | "threshold" | "overflow", + settings: { provider?: string; model?: string; thinkingLevel?: ThinkingLevel }, ): Promise { // Resolve the request only when Pi summarizes itself: routing may call models or fail. - const request = await this._getSummarizationRequestAuth(model, signal); + const summaryConfig = this._getSummarizationModel(settings, model, "compaction"); + const request = await this._getSummarizationRequestAuth(summaryConfig.model, signal, summaryConfig.thinkingLevel); return compact( preparation, request.model, @@ -2904,11 +2937,9 @@ export class AgentSession { try { const model = this.model; - if (!model) { - throw new Error(formatNoModelSelectedMessage()); - } - const settings = this.settingsManager.getCompactionSettings(model); + // Report invalid model settings before preparation errors or extension hooks. + this._getSummarizationModel(settings, model, "compaction"); const pathEntries = this.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, settings); @@ -2966,6 +2997,7 @@ export class AgentSession { customInstructions, this._compactionAbortController.signal, "manual", + settings, ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; @@ -3233,7 +3265,7 @@ export class AgentSession { let cancelledByExtension = false; try { - if (!model) { + if (!model && settings.provider === undefined && settings.model === undefined) { return false; } @@ -3295,6 +3327,7 @@ export class AgentSession { undefined, abortController.signal, reason, + settings, ); summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; @@ -4118,8 +4151,15 @@ export class AgentSession { return { cancelled: false }; } + const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); + // Model required for summarization - if (options.summarize && !this.model) { + if ( + options.summarize && + !this.model && + branchSummarySettings.provider === undefined && + branchSummarySettings.model === undefined + ) { throw new Error("No model available for summarization"); } @@ -4194,9 +4234,13 @@ export class AgentSession { let summaryUsage: Usage | undefined; if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { const signal = abortController.signal; - const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); + const { model, thinkingLevel } = this._getSummarizationModel( + branchSummarySettings, + this.model, + "branchSummary", + ); const result = await generateBranchSummary(entriesToSummarize, { - ...(await this._getSummarizationRequestAuth(this.model!, signal)), + ...(await this._getSummarizationRequestAuth(model, signal, thinkingLevel)), signal, customInstructions, replaceInstructions, diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 07a58ac6c..1d415b5e1 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -121,6 +121,9 @@ export interface CompactionSettings { enabled: boolean; reserveTokens: number; keepRecentTokens: number; + provider?: string; + model?: string; + thinkingLevel?: ThinkingLevel; } export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 99bbeade0..81a42276d 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -884,18 +884,33 @@ export class SettingsManager { enabled: boolean; reserveTokens: number; keepRecentTokens: number; + provider?: string; + model?: string; + thinkingLevel?: ThinkingLevel; } { return { enabled: this.getCompactionEnabled(), reserveTokens: this.getCompactionReserveTokens(model), keepRecentTokens: this.getCompactionKeepRecentTokens(model), + provider: this.settings.compaction?.provider, + model: this.settings.compaction?.model, + thinkingLevel: this.settings.compaction?.thinkingLevel, }; } - getBranchSummarySettings(): { reserveTokens: number; skipPrompt: boolean } { + getBranchSummarySettings(): { + reserveTokens: number; + skipPrompt: boolean; + provider?: string; + model?: string; + thinkingLevel?: ThinkingLevel; + } { return { reserveTokens: this.settings.branchSummary?.reserveTokens ?? SETTINGS_DEFAULTS.branchSummary.reserveTokens, skipPrompt: this.settings.branchSummary?.skipPrompt ?? SETTINGS_DEFAULTS.branchSummary.skipPrompt, + provider: this.settings.branchSummary?.provider, + model: this.settings.branchSummary?.model, + thinkingLevel: this.settings.branchSummary?.thinkingLevel, }; } @@ -903,6 +918,23 @@ export class SettingsManager { return this.settings.branchSummary?.skipPrompt ?? SETTINGS_DEFAULTS.branchSummary.skipPrompt; } + setSummarizationConfig( + settingsKey: "compaction" | "branchSummary", + provider: string, + model: string, + thinkingLevel: ThinkingLevel, + ): void { + const settings = this.globalSettings[settingsKey] ?? {}; + this.globalSettings[settingsKey] = settings; + settings.provider = provider; + settings.model = model; + settings.thinkingLevel = thinkingLevel; + this.markModified(settingsKey, "provider"); + this.markModified(settingsKey, "model"); + this.markModified(settingsKey, "thinkingLevel"); + this.save(); + } + getRetryEnabled(): boolean { return this.settings.retry?.enabled ?? SETTINGS_DEFAULTS.retry.enabled; } diff --git a/packages/coding-agent/src/core/settings-schema.ts b/packages/coding-agent/src/core/settings-schema.ts index 1ef797338..4bd91ab3a 100644 --- a/packages/coding-agent/src/core/settings-schema.ts +++ b/packages/coding-agent/src/core/settings-schema.ts @@ -16,6 +16,9 @@ const CompactionModelOverrideSchema = Type.Object({ }); const CompactionSettingsSchema = Type.Object({ + provider: Type.Optional(Type.String()), + model: Type.Optional(Type.String()), + thinkingLevel: Type.Optional(ModelThinkingLevelSchema), enabled: Type.Optional(Type.Boolean({ default: SETTINGS_DEFAULTS.compaction.enabled })), reserveTokens: Type.Optional(nonNegativeSafeInteger({ default: SETTINGS_DEFAULTS.compaction.reserveTokens })), keepRecentTokens: Type.Optional(nonNegativeSafeInteger({ default: SETTINGS_DEFAULTS.compaction.keepRecentTokens })), @@ -27,6 +30,9 @@ const CompactionSettingsSchema = Type.Object({ }); const BranchSummarySettingsSchema = Type.Object({ + provider: Type.Optional(Type.String()), + model: Type.Optional(Type.String()), + thinkingLevel: Type.Optional(ModelThinkingLevelSchema), reserveTokens: Type.Optional( Type.Number({ description: "Tokens reserved for the prompt and LLM response.", diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index ac7e74a66..c217b109b 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -25,6 +25,31 @@ describe("SettingsManager", () => { } }); + describe("summarization settings", () => { + it("persists compaction and branch summary model configurations", () => { + const manager = SettingsManager.inMemory({ + compaction: { enabled: false }, + branchSummary: { skipPrompt: true }, + }); + + manager.setSummarizationConfig("compaction", "anthropic", "compact-model", "low"); + manager.setSummarizationConfig("branchSummary", "openai", "branch-model", "high"); + + expect(manager.getGlobalSettings().compaction).toEqual({ + enabled: false, + provider: "anthropic", + model: "compact-model", + thinkingLevel: "low", + }); + expect(manager.getGlobalSettings().branchSummary).toEqual({ + skipPrompt: true, + provider: "openai", + model: "branch-model", + thinkingLevel: "high", + }); + }); + }); + describe("preserves externally added settings", () => { it("should preserve enabledModels when changing thinking level", async () => { // Create initial settings file diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 3f72a3959..2050400a0 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -57,12 +57,12 @@ function createAssistant( function useSummaryStreamFn( harness: Harness, summary: string, - onRequest?: (context: TranscriptContext, options: SimpleStreamOptions | undefined) => void, + onRequest?: (context: TranscriptContext, options: SimpleStreamOptions | undefined, model: Model) => void, ): () => number { let callCount = 0; harness.session.agent.streamFunction = (model, context, options) => { callCount++; - onRequest?.(context, options); + onRequest?.(context, options, model); const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message: AssistantMessage = { @@ -379,6 +379,90 @@ describe("AgentSession compaction characterization", () => { ); }); + it("uses configured compaction model and thinking level with the session provider", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-summary", reasoning: true }], + settings: { + compaction: { keepRecentTokens: 1, model: "faux-summary", thinkingLevel: "high" }, + }, + }); + harnesses.push(harness); + seedCompactableSession(harness); + const calls: Array<{ modelId: string; reasoning: SimpleStreamOptions["reasoning"] }> = []; + useSummaryStreamFn(harness, "summary from configured model", (_context, options, model) => { + calls.push({ modelId: model.id, reasoning: options?.reasoning }); + }); + + const result = await harness.session.compact(); + + expect(result.summary).toContain("summary from configured model"); + expect(calls).toEqual([{ modelId: "faux-summary", reasoning: "high" }]); + }); + + it.each([ + { thinkingLevel: undefined, reasoning: "medium" }, + { thinkingLevel: "high", reasoning: "high" }, + { thinkingLevel: "off", reasoning: undefined }, + { thinkingLevel: "max", reasoning: "high" }, + ] as const)( + "uses configured manual compaction model and thinking without mutation: $thinkingLevel", + async ({ thinkingLevel, reasoning }) => { + const harness = await createHarness({ + models: [ + { id: "faux-1", reasoning: true }, + { id: "faux-configured", reasoning: true }, + ], + settings: { + compaction: { keepRecentTokens: 1, model: "faux-configured", thinkingLevel }, + }, + }); + harnesses.push(harness); + harness.session.agent.state.thinkingLevel = "medium"; + seedCompactableSession(harness); + const mainModel = harness.session.model; + const mainThinkingLevel = harness.session.thinkingLevel; + const settingsBefore = { + compaction: harness.settingsManager.getCompactionSettings(), + defaultProvider: harness.settingsManager.getDefaultProvider(), + defaultModel: harness.settingsManager.getDefaultModel(), + defaultThinkingLevel: harness.settingsManager.getDefaultThinkingLevel(), + }; + const calls: Array<{ modelId: string; reasoning: SimpleStreamOptions["reasoning"] }> = []; + useSummaryStreamFn(harness, "configured summary", (_context, options, model) => { + calls.push({ modelId: model.id, reasoning: options?.reasoning }); + }); + + const result = await harness.session.compact(); + + expect(result.summary).toContain("configured summary"); + expect(calls).toEqual([{ modelId: "faux-configured", reasoning }]); + expect(harness.session.model).toBe(mainModel); + expect(harness.session.thinkingLevel).toBe(mainThinkingLevel); + expect({ + compaction: harness.settingsManager.getCompactionSettings(), + defaultProvider: harness.settingsManager.getDefaultProvider(), + defaultModel: harness.settingsManager.getDefaultModel(), + defaultThinkingLevel: harness.settingsManager.getDefaultThinkingLevel(), + }).toEqual(settingsBefore); + expect( + harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "model_change" || entry.type === "thinking_level_change"), + ).toHaveLength(0); + }, + ); + + it("points to the relevant settings when a summarization model is not found", async () => { + const harness = await createHarness({ + settings: { compaction: { provider: "missing-provider" } }, + }); + harnesses.push(harness); + + await expect(harness.session.compact()).rejects.toThrow( + "Summarization model not found: missing-provider/faux-1. Check compaction.provider and compaction.model in your settings.", + ); + }); + it("auto-compacts with a custom streamFn when registry auth is absent", async () => { const harness = await createHarness({ withConfiguredAuth: false }); harnesses.push(harness); @@ -439,6 +523,118 @@ describe("AgentSession compaction characterization", () => { ]); }); + it("auto-compacts with the configured summarization model when the session model is absent", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-summary" }], + settings: { compaction: { keepRecentTokens: 1, provider: "faux", model: "faux-summary" } }, + }); + harnesses.push(harness); + seedCompactableSession(harness); + harness.session.agent.state.model = undefined as unknown as Model; + const getStreamCallCount = useSummaryStreamFn(harness, "summary without session model"); + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + + await sessionInternals._runAutoCompaction("threshold", false); + + const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction"); + expect(compactionEntries).toHaveLength(1); + expect(getStreamCallCount()).toBe(1); + }); + + it("uses configured branch summary model and thinking level", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-branch-summary", reasoning: true }], + settings: { + branchSummary: { provider: "faux", model: "faux-branch-summary", thinkingLevel: "medium" }, + }, + }); + harnesses.push(harness); + const rootId = harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "root" }], + timestamp: Date.now(), + }); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "branch work" }], + timestamp: Date.now(), + }); + harness.sessionManager.appendMessage(createAssistant(harness, { stopReason: "stop", totalTokens: 10 })); + const calls: Array<{ modelId: string; reasoning: SimpleStreamOptions["reasoning"] }> = []; + harness.session.agent.state.model = undefined as unknown as Model; + useSummaryStreamFn(harness, "branch summary from configured model", (_context, options, model) => { + calls.push({ modelId: model.id, reasoning: options?.reasoning }); + }); + + const result = await harness.session.navigateTree(rootId, { summarize: true }); + + expect(result.summaryEntry?.summary).toContain("branch summary from configured model"); + expect(calls).toEqual([{ modelId: "faux-branch-summary", reasoning: "medium" }]); + }); + + it.each([ + { thinkingLevel: undefined, reasoning: "medium" }, + { thinkingLevel: "high", reasoning: "high" }, + { thinkingLevel: "off", reasoning: undefined }, + { thinkingLevel: "max", reasoning: "high" }, + ] as const)( + "uses configured branch summary model and thinking without mutation: $thinkingLevel", + async ({ thinkingLevel, reasoning }) => { + const harness = await createHarness({ + models: [ + { id: "faux-1", reasoning: true }, + { id: "faux-configured", reasoning: true }, + ], + settings: { + branchSummary: { provider: "faux", model: "faux-configured", thinkingLevel }, + }, + }); + harnesses.push(harness); + harness.session.agent.state.thinkingLevel = "medium"; + const rootId = harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "root" }], + timestamp: Date.now(), + }); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "branch work" }], + timestamp: Date.now(), + }); + harness.sessionManager.appendMessage(createAssistant(harness, { stopReason: "stop", totalTokens: 10 })); + const mainModel = harness.session.model; + const mainThinkingLevel = harness.session.thinkingLevel; + const settingsBefore = { + branchSummary: harness.settingsManager.getBranchSummarySettings(), + defaultProvider: harness.settingsManager.getDefaultProvider(), + defaultModel: harness.settingsManager.getDefaultModel(), + defaultThinkingLevel: harness.settingsManager.getDefaultThinkingLevel(), + }; + const calls: Array<{ modelId: string; reasoning: SimpleStreamOptions["reasoning"] }> = []; + useSummaryStreamFn(harness, "branch summary from configured model", (_context, options, model) => { + calls.push({ modelId: model.id, reasoning: options?.reasoning }); + }); + + const result = await harness.session.navigateTree(rootId, { summarize: true }); + + expect(result.summaryEntry?.summary).toContain("branch summary from configured model"); + expect(calls).toEqual([{ modelId: "faux-configured", reasoning }]); + expect(harness.session.model).toBe(mainModel); + expect(harness.session.thinkingLevel).toBe(mainThinkingLevel); + expect({ + branchSummary: harness.settingsManager.getBranchSummarySettings(), + defaultProvider: harness.settingsManager.getDefaultProvider(), + defaultModel: harness.settingsManager.getDefaultModel(), + defaultThinkingLevel: harness.settingsManager.getDefaultThinkingLevel(), + }).toEqual(settingsBefore); + expect( + harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "model_change" || entry.type === "thinking_level_change"), + ).toHaveLength(0); + }, + ); + it("compacts and resumes after a length stop below the desired output limit", async () => { const harness = await createHarness({ models: [{ id: "faux-1", contextWindow: 1000, maxTokens: 100 }],