diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx index 16a338e6b1e..f7e9cbbf92b 100644 --- a/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx @@ -1,7 +1,8 @@ import type { FC } from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useMutation, useQueries, useQuery, useQueryClient } from "react-query"; import { deleteUserCompactionThreshold, + organizationChatModelOverrides, updateUserCompactionThreshold, userChatProviderConfigs, userCompactionThresholds, @@ -19,6 +20,25 @@ const AgentSettingsCompactionPage: FC = () => { ); const providerConfigsQuery = useQuery(userChatProviderConfigs()); const thresholdsQuery = useQuery(userCompactionThresholds()); + // Only refines the displayed trigger point; a failed request falls back + // to the chat model's own window. + const modelOverrideQueries = useQueries({ + queries: organizations.map((organization) => + organizationChatModelOverrides(organization.id), + ), + }); + const compactionModelIDByOrganization = new Map(); + for (const [index, query] of modelOverrideQueries.entries()) { + const compactionOverride = query.data?.overrides.find( + (override) => override.context === "compaction", + ); + if (compactionOverride) { + compactionModelIDByOrganization.set( + organizations[index].id, + compactionOverride.model_config_id, + ); + } + } const saveThresholdMutation = useMutation( updateUserCompactionThreshold(queryClient), ); @@ -44,6 +64,7 @@ const AgentSettingsCompactionPage: FC = () => { models={organizationModels.models} providerTypeByID={providerTypeByID} organizations={organizations} + compactionModelIDByOrganization={compactionModelIDByOrganization} modelsError={organizationModels.error ?? organizationModels.partialError} isLoadingModels={organizationModels.isLoading} thresholds={thresholdsQuery.data?.thresholds} diff --git a/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx index 61136d266f2..1fa6aa89c99 100644 --- a/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsCompactionPageView.tsx @@ -7,6 +7,7 @@ export interface AgentSettingsCompactionPageViewProps { models: readonly TypesGen.ChatModel[] | undefined; providerTypeByID: ReadonlyMap; organizations: readonly TypesGen.Organization[]; + compactionModelIDByOrganization?: ReadonlyMap; modelsError: unknown; isLoadingModels: boolean; thresholds: readonly TypesGen.UserChatCompactionThreshold[] | undefined; @@ -25,6 +26,7 @@ export const AgentSettingsCompactionPageView: FC< models, providerTypeByID, organizations, + compactionModelIDByOrganization, modelsError, isLoadingModels, thresholds, @@ -43,6 +45,7 @@ export const AgentSettingsCompactionPageView: FC< models={models ?? []} providerTypeByID={providerTypeByID} organizations={organizations} + compactionModelIDByOrganization={compactionModelIDByOrganization} modelsError={modelsError} isLoadingModels={isLoadingModels} thresholds={thresholds} diff --git a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx index 8a3d57eb567..e9b9ccf369f 100644 --- a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx +++ b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.stories.tsx @@ -94,6 +94,36 @@ export const Default: Story = { }, }; +export const ContextWindowTracksDraft: Story = { + name: "Context Window Tracks Draft", + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const gpt4oInput = await canvas.findByRole("textbox", { + name: /GPT-4o compaction threshold/i, + }); + + // 128K window: default 80% compacts at ~102K, the draft moves it to ~64K. + await userEvent.type(gpt4oInput, "50"); + }, +}; + +export const CompactionOverrideShrinksWindow: Story = { + name: "Compaction Override Shrinks Window", + args: { + // The organization summarizes with the 16K model, so both enabled + // models show a 16K compaction window instead of their own. + compactionModelIDByOrganization: new Map([ + [MockChatModel.organization_id, "model-3"], + ]), + }, +}; + +export const UnknownContextWindow: Story = { + args: { + models: [{ ...mockModels[0], context_limit: 0 }], + }, +}; + export const EmptyOrganizationDisplayNameFallsBackToName: Story = { args: { organizations: [organizationWithEmptyDisplayName], diff --git a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx index 5c0c0a8e232..b7e7049de32 100644 --- a/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx +++ b/site/src/pages/AgentsPage/components/UserCompactionThresholdSettings.tsx @@ -29,13 +29,23 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { formatContextLimit } from "#/modules/aiModels/ModelSelector"; import { ProviderIcon } from "#/modules/aiModels/ProviderIcon"; import { formatProviderLabel } from "#/utils/aiProviders"; +import { + compactionTriggerTokens, + resolveCompactionContextLimit, +} from "../utils/modelOptions"; interface UserCompactionThresholdSettingsProps { models: readonly TypesGen.ChatModel[]; providerTypeByID: ReadonlyMap; organizations: readonly TypesGen.Organization[]; + /** + * Organization ID to the model config the organization routes compaction + * through. Missing entries mean the chat model summarizes itself. + */ + compactionModelIDByOrganization?: ReadonlyMap; modelsError?: unknown; isLoadingModels?: boolean; thresholds: readonly TypesGen.UserChatCompactionThreshold[] | undefined; @@ -48,6 +58,8 @@ interface UserCompactionThresholdSettingsProps { onResetThreshold: (modelId: string) => Promise; } +const noCompactionOverrides: ReadonlyMap = new Map(); + const parseThresholdDraft = (value: string): number | null => { const trimmedValue = value.trim(); if (!/^\d+$/.test(trimmedValue)) { @@ -80,6 +92,7 @@ export const UserCompactionThresholdSettings: FC< models, providerTypeByID, organizations, + compactionModelIDByOrganization = noCompactionOverrides, modelsError, isLoadingModels, thresholds, @@ -330,6 +343,7 @@ export const UserCompactionThresholdSettings: FC< Model + Context Default Threshold @@ -361,6 +375,21 @@ export const UserCompactionThresholdSettings: FC< const organizationName = organizationNameByID.get(modelConfig.organization_id) ?? modelConfig.organization_id; + // Prefer the typed draft so the trigger point tracks + // what the user is about to save. + const effectiveThreshold = + parsedDraftValue ?? + existingOverride ?? + modelConfig.compression_threshold; + const contextLimit = resolveCompactionContextLimit( + modelConfig, + models, + compactionModelIDByOrganization, + ); + const triggerTokens = compactionTriggerTokens( + contextLimit, + effectiveThreshold, + ); return ( @@ -388,6 +417,20 @@ export const UserCompactionThresholdSettings: FC<

)} + + {contextLimit > 0 ? ( +
+ {formatContextLimit(contextLimit)} tokens + {triggerTokens !== undefined && ( + + Compacts at ~{formatContextLimit(triggerTokens)} + + )} +
+ ) : ( + Unknown + )} +
{modelConfig.compression_threshold}% @@ -484,7 +527,7 @@ export const UserCompactionThresholdSettings: FC< - +
{isSavedVisible ? ( diff --git a/site/src/pages/AgentsPage/utils/modelOptions.test.ts b/site/src/pages/AgentsPage/utils/modelOptions.test.ts index 188590d5527..6cc6e46b6e6 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.test.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.test.ts @@ -11,6 +11,7 @@ import { MockChatProviderConfig, } from "#/testHelpers/chatModels"; import { + compactionTriggerTokens, countConfiguredProviderConfigs, filterModelsWithEnabledProvider, formatProviderLabel, @@ -25,6 +26,7 @@ import { NIL_UUID, providerInfoByIDFromUserConfigs, providerTypeByIDFromUserConfigs, + resolveCompactionContextLimit, resolveCompactionThreshold, resolveModelOptionId, resolveModelSelector, @@ -1210,3 +1212,89 @@ describe("resolveCompactionThreshold", () => { expect(resolveCompactionThreshold("missing", [], models)).toBe(undefined); }); }); + +describe("resolveCompactionContextLimit", () => { + const chatModel = createConfig({ + id: "chat", + ai_provider_id: "prov-openai", + model: "gpt-4o", + context_limit: 1_000, + }); + const smallSummarizer = createConfig({ + id: "small", + ai_provider_id: "prov-openai", + model: "gpt-4o-mini", + context_limit: 100, + }); + const largeSummarizer = createConfig({ + id: "large", + ai_provider_id: "prov-anthropic", + model: "claude", + context_limit: 5_000, + }); + const models = [chatModel, smallSummarizer, largeSummarizer]; + + it("uses the chat model window without an override", () => { + expect(resolveCompactionContextLimit(chatModel, models, new Map())).toBe( + 1_000, + ); + }); + + it("uses the override window when it is smaller", () => { + expect( + resolveCompactionContextLimit( + chatModel, + models, + new Map([[testOrganizationID, "small"]]), + ), + ).toBe(100); + }); + + it("keeps the chat model window when the override is larger", () => { + expect( + resolveCompactionContextLimit( + chatModel, + models, + new Map([[testOrganizationID, "large"]]), + ), + ).toBe(1_000); + }); + + it("falls back to the override window when the chat window is unknown", () => { + expect( + resolveCompactionContextLimit( + { ...chatModel, context_limit: 0 }, + models, + new Map([[testOrganizationID, "small"]]), + ), + ).toBe(100); + }); + + it("ignores overrides for other organizations and unknown models", () => { + expect( + resolveCompactionContextLimit( + chatModel, + models, + new Map([ + ["other-org", "small"], + [testOrganizationID, "missing"], + ]), + ), + ).toBe(1_000); + }); +}); + +describe("compactionTriggerTokens", () => { + it("scales the window by the threshold", () => { + expect(compactionTriggerTokens(128_000, 80)).toBe(102_400); + expect(compactionTriggerTokens(1_000, 70)).toBe(700); + }); + + it("returns undefined when the window is unknown", () => { + expect(compactionTriggerTokens(0, 80)).toBe(undefined); + }); + + it("returns undefined at 100% because compaction never triggers", () => { + expect(compactionTriggerTokens(128_000, 100)).toBe(undefined); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/modelOptions.ts b/site/src/pages/AgentsPage/utils/modelOptions.ts index b58f446842c..5f03bd1c886 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.ts @@ -342,6 +342,42 @@ export function resolveCompactionThreshold( return model.compression_threshold; } +/** + * Context window the compaction trigger is measured against. Mirrors the + * backend: when the organization routes compaction to an override model, + * the history must also fit that model's window, so the smaller of the + * two limits wins. Returns 0 when neither limit is known. + */ +export function resolveCompactionContextLimit( + model: TypesGen.ChatModel, + models: readonly TypesGen.ChatModel[], + compactionModelIDByOrganization: ReadonlyMap, +): number { + const chatLimit = model.context_limit > 0 ? model.context_limit : 0; + const overrideID = compactionModelIDByOrganization.get(model.organization_id); + const overrideLimit = + models.find((candidate) => candidate.id === overrideID)?.context_limit ?? 0; + if (overrideLimit > 0 && (chatLimit <= 0 || overrideLimit < chatLimit)) { + return overrideLimit; + } + return chatLimit; +} + +/** + * Token count at which compaction triggers for the given context window and + * threshold, or undefined when the window is unknown or compaction is + * disabled (100%). + */ +export function compactionTriggerTokens( + contextLimit: number, + thresholdPercent: number, +): number | undefined { + if (contextLimit <= 0 || thresholdPercent >= 100) { + return undefined; + } + return Math.round((contextLimit * thresholdPercent) / 100); +} + export const getModelSelectorPlaceholder = ( modelOptions: readonly ModelSelectorOption[], isModelCatalogLoading: boolean,