From de6fbf1efd18d9fcaccefc1474ca4c4124e31930 Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Sat, 22 Aug 2026 16:06:27 +0300 Subject: [PATCH] feat(agent-core-v2): add dedicated compaction model with fallback Mirror the visual/secondary-model pattern: when the compaction-model experiment is enabled and [compaction_model] is configured, context compaction uses that dedicated model instead of the current one. If the dedicated model errors or is inaccessible (an uncatalogued alias), compaction transparently falls back to the current model on the same round, so the dedicated model is never a single point of failure. Adds resolver unit tests and end-to-end fallback integration tests. --- .changeset/compaction-model-option.md | 5 + .../fullCompaction/fullCompactionService.ts | 59 ++++- .../src/agent/llmRequester/llmRequester.ts | 1 + .../agent/llmRequester/llmRequesterService.ts | 5 +- .../src/agent/profile/profile.ts | 1 + .../src/agent/profile/profileService.ts | 14 ++ .../kosongConfig/compactionModelOverlay.ts | 104 +++++++++ .../src/app/kosongConfig/configSection.ts | 22 ++ .../agent-core-v2/src/app/telemetry/events.ts | 4 + packages/agent-core-v2/src/index.ts | 25 ++- .../src/session/compaction/configSection.ts | 128 +++++++++++ .../src/session/compaction/flag.ts | 28 +++ .../fullCompaction/compaction-model.test.ts | 202 ++++++++++++++++++ .../session/compaction/configSection.test.ts | 166 ++++++++++++++ 14 files changed, 761 insertions(+), 3 deletions(-) create mode 100644 .changeset/compaction-model-option.md create mode 100644 packages/agent-core-v2/src/app/kosongConfig/compactionModelOverlay.ts create mode 100644 packages/agent-core-v2/src/session/compaction/configSection.ts create mode 100644 packages/agent-core-v2/src/session/compaction/flag.ts create mode 100644 packages/agent-core-v2/test/agent/fullCompaction/compaction-model.test.ts create mode 100644 packages/agent-core-v2/test/session/compaction/configSection.test.ts diff --git a/.changeset/compaction-model-option.md b/.changeset/compaction-model-option.md new file mode 100644 index 00000000000..e6a4fe903a8 --- /dev/null +++ b/.changeset/compaction-model-option.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": minor +--- + +Add an opt-in dedicated compaction model: when the `compaction-model` experiment is enabled and `[compaction_model]` is configured, context compaction uses that model instead of the current one, and transparently falls back to the current model if it errors or is inaccessible. diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 4fa80358a07..014355e4f72 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -18,6 +18,13 @@ import { TurnStarted } from '#/agent/loop/turnEvents'; import { TurnEnded } from '#/agent/loop/turnOps'; import { isAbortError } from '#/_base/utils/abort'; import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; +import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; +import { + compactionDisplayModel, + compactionModelBindingFor, + wrapCompactionModelError, +} from '#/session/compaction/configSection'; import { agentContextOfScope, IAgentScopeContext, @@ -152,6 +159,8 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom @ILogService private readonly log: ILogService, @IAgentLoopService private readonly loopService: IAgentLoopService, @IAgentStateService private readonly states: IAgentStateService, + @IConfigService private readonly configService: IConfigService, + @IFlagService private readonly flags: IFlagService, ) { super(); this.states.contributeState(fullCompactionKey); @@ -634,12 +643,41 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom const resolvedModel = this.profile.resolveModelContext(); thinkingEffort = resolvedModel.thinkingLevel; const maxContextTokens = resolvedModel.modelCapabilities.max_context_tokens; + const currentModelAlias = resolvedModel.modelAlias; const defaultCompactionCap = maxContextTokens > 0 ? Math.min(maxContextTokens, DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS) : undefined; const compactionMaxOutputSize = resolvedModel.maxOutputSize ?? defaultCompactionCap; + const binding = compactionModelBindingFor(this.configService, this.flags, { + modelAlias: currentModelAlias, + thinkingLevel: thinkingEffort, + }); + const dedicatedModelAlias = binding.model; + let hasDedicatedModel = dedicatedModelAlias !== currentModelAlias; + let usingFallbackModel = false; + let boundModel = resolvedModel; + if (hasDedicatedModel) { + try { + boundModel = this.profile.resolveModelContextFor(dedicatedModelAlias); + } catch (error) { + this.log.warn( + `compaction model "${dedicatedModelAlias}" is not configured; falling back to current model "${currentModelAlias}"`, + { cause: wrapCompactionModelError(error, dedicatedModelAlias) }, + ); + hasDedicatedModel = false; + boundModel = resolvedModel; + } + } + const boundMaxOutputSize = boundModel.maxOutputSize ?? defaultCompactionCap; + let compactionRequestModel = hasDedicatedModel ? dedicatedModelAlias : undefined; + let effectiveModelAlias = hasDedicatedModel ? dedicatedModelAlias : currentModelAlias; + const effectiveMaxOutputSize = Math.min( + compactionMaxOutputSize ?? DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS, + boundMaxOutputSize ?? DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS, + ); + const customInstruction = data.instruction?.trim() ?? ''; const instruction = renderPrompt(compactionInstructionTemplate, { custom_instruction_block: @@ -661,7 +699,8 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom const request = this.llmRequester.start( { messages, - maxOutputSize: compactionMaxOutputSize, + maxOutputSize: effectiveMaxOutputSize, + model: compactionRequestModel, source: { type: 'operation', turnId: active.originTurnId, @@ -716,6 +755,22 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom retryCount = 0; continue; } + if ( + hasDedicatedModel && + !usingFallbackModel && + (isRetryableGenerateError(unwrappedError) || + !(error instanceof CompactionTruncatedError)) + ) { + usingFallbackModel = true; + effectiveModelAlias = currentModelAlias; + compactionRequestModel = undefined; + this.log.warn( + `compaction model "${dedicatedModelAlias}" failed; falling back to current model "${currentModelAlias}"`, + { cause: wrapCompactionModelError(error, dedicatedModelAlias) }, + ); + retryCount = 0; + continue; + } if (!isRetryableGenerateError(unwrappedError)) { throw error; } @@ -764,6 +819,8 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom round: 1, thinking_effort: thinkingEffort, trace_id: attempt.traceId, + model: effectiveModelAlias, + model_display: compactionDisplayModel(this.configService, effectiveModelAlias), ...usageTelemetry(attempt.usage), }; this.telemetry.track2('compaction_finished', properties); diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts index dceb42ac37b..88a3fb7aea4 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts @@ -42,6 +42,7 @@ export interface AgentLLMRequestOverrides { systemPrompt?: string; source?: AgentLLMRequestSource; maxOutputSize?: number; + model?: string; } export interface AgentLLMRequestTask { diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 45dc08dbe62..dfada1d721a 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -582,7 +582,10 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { private resolveRequest(overrides: AgentLLMRequestOverrides): ResolvedLLMRequest { const turnConfig = this.resolveTurnConfig(overrides.source); - const resolved = turnConfig?.resolved ?? this.profile.resolveModelContext(); + const resolved = + overrides.model !== undefined + ? this.profile.resolveModelContextFor(overrides.model) + : turnConfig?.resolved ?? this.profile.resolveModelContext(); const baseParams = turnConfig?.params ?? this.profile.resolveRequestParams(); const budgetParams = completionBudgetParams({ budget: resolveCompletionBudget({ diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index 529c23e4289..f21ce33b009 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -127,6 +127,7 @@ export interface IAgentProfileService { data(): ProfileData; getEffectiveThinkingLevel(): ThinkingEffort; resolveModelContext(): ProfileModelContext; + resolveModelContextFor(modelAlias: string): ProfileModelContext; resolveRequestParams(): ModelRequestParams; getModelCapabilities(): ModelCapability; getMaxOutputSize(): number | undefined; diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 2b504afa03d..08d08df5a97 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -504,6 +504,20 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ }; } + resolveModelContextFor(modelAlias: string): ProfileModelContext { + const model = this.modelCatalog.get(modelAlias); + const loopControl = this.config.get('loopControl'); + return { + modelAlias, + modelCapabilities: model.capabilities, + maxOutputSize: model.maxOutputSize, + alwaysThinking: model.alwaysThinking || undefined, + thinkingLevel: this.resolveThinkingState(model).effective, + reservedContextSize: loopControl?.reservedContextSize, + compactionTriggerRatio: loopControl?.compactionTriggerRatio, + }; + } + resolveRequestParams(): ModelRequestParams { const model = this.tryResolveRawModel(); const thinking = this.resolveThinkingState(model); diff --git a/packages/agent-core-v2/src/app/kosongConfig/compactionModelOverlay.ts b/packages/agent-core-v2/src/app/kosongConfig/compactionModelOverlay.ts new file mode 100644 index 00000000000..754b81dc5a0 --- /dev/null +++ b/packages/agent-core-v2/src/app/kosongConfig/compactionModelOverlay.ts @@ -0,0 +1,104 @@ +import type { ConfigEffectiveOverlay } from '#/app/config/config'; +import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; +import { isPlainObject } from '#/app/config/toml'; +import type { ModelOverride } from '#/kosong/model/model'; + +import { + COMPACTION_MODEL_SECTION, + DEFAULT_MODEL_SECTION, + MODELS_SECTION, + type CompactionModelConfig, +} from './configSection'; + +/** + * `kosongConfig` domain — `[compaction_model]` derived-entry overlay. + * + * Compaction-model mirror of {@link visualModelOverlay}: when the + * compaction-model recipe carries patch fields, synthesizes the derived + * registry entry ({@link COMPACTION_DERIVED_MODEL_ID}) into the effective + * `models` view — a copy of the pointed entry with the patch merged into its + * `overrides` block (patch wins conflicts) and `aliases` dropped, so the + * derived entry never competes in name/alias routing. Compaction-model binding + * then resolves it by name through the standard catalog path, and the patch + * rides the same `effectiveModelConfig` merge as any `models.*.overrides` + * (including its `supportEfforts` / `defaultEffort` pruning and input + * clamping). + * + * The synthesized entry lives ONLY in the in-memory effective view: `strip` + * removes it from `models` writes so it never reaches `config.toml`, and the + * persistence bridge's deep-equal guards keep the two-way sync silent. `strip` + * also rolls back a `defaultModel` pointer set to the derived id (restoring the + * raw value). Nothing is synthesized when the recipe has no patch fields (when + * `compaction.model` is unset), or when the pointed entry does not exist. The + * id is reserved: a user-configured entry under it is stripped on write all the + * same. + * + * Self-registered at module load via `registerConfigOverlay`; it is imported + * for side effects after the visual-model overlay. + */ +export const COMPACTION_DERIVED_MODEL_ID = '__compaction__'; + +export function compactionModelPatch( + compaction: CompactionModelConfig | undefined, +): ModelOverride | undefined { + if (compaction === undefined) return undefined; + const { model: _model, ...patch } = compaction; + return Object.keys(patch).length > 0 ? patch : undefined; +} + +function asRecord(value: unknown): Record { + return isPlainObject(value) ? value : {}; +} + +function withoutKey(value: unknown, key: string): unknown { + if (!isPlainObject(value) || !(key in value)) return value; + const out: Record = { ...value }; + delete out[key]; + return out; +} + +export const compactionModelOverlay: ConfigEffectiveOverlay = { + apply(effective, _getEnv, validate) { + const compaction = effective[COMPACTION_MODEL_SECTION] as + | CompactionModelConfig + | undefined; + const patch = compactionModelPatch(compaction); + const baseId = compaction?.model; + if ( + patch === undefined || + baseId === undefined || + baseId === COMPACTION_DERIVED_MODEL_ID + ) { + return []; + } + const models = asRecord(effective[MODELS_SECTION]); + const base = models[baseId]; + if (!isPlainObject(base)) return []; + const { overrides: baseOverrides, aliases: _aliases, ...baseFields } = base; + const derived: Record = { + ...baseFields, + overrides: { ...asRecord(baseOverrides), ...patch }, + }; + effective[MODELS_SECTION] = validate(MODELS_SECTION, { + ...models, + [COMPACTION_DERIVED_MODEL_ID]: derived, + }); + return [MODELS_SECTION]; + }, + + strip(domain, value, rawSnake) { + switch (domain) { + case MODELS_SECTION: + return withoutKey(value, COMPACTION_DERIVED_MODEL_ID); + case DEFAULT_MODEL_SECTION: + if (value !== COMPACTION_DERIVED_MODEL_ID) return value; + return typeof rawSnake['default_model'] === 'string' + ? rawSnake['default_model'] + : undefined; + default: + return value; + } + }, +}; + +registerConfigOverlay(compactionModelOverlay); diff --git a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts index 3efcaa6178f..dc76536ea3d 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts @@ -335,6 +335,28 @@ registerConfigSection(VISUAL_MODEL_SECTION, VisualModelConfigSchema, { }); +export const COMPACTION_MODEL_SECTION = 'compactionModel'; + +export const COMPACTION_MODEL_ENV = 'KIMI_COMPACTION_MODEL'; +export const COMPACTION_MODEL_EFFORT_ENV = 'KIMI_COMPACTION_EFFORT'; + +export const CompactionModelConfigSchema = ModelOverrideSchema.extend({ + model: z.string().min(1).optional(), +}); + +export type CompactionModelConfig = z.infer; + +export const compactionModelEnvBindings = envBindings(CompactionModelConfigSchema, { + model: { env: COMPACTION_MODEL_ENV, parse: parseNonEmptyEnv }, + defaultEffort: { env: COMPACTION_MODEL_EFFORT_ENV, parse: parseNonEmptyEnv }, +}); + +registerConfigSection(COMPACTION_MODEL_SECTION, CompactionModelConfigSchema, { + env: compactionModelEnvBindings, + stripEnv: stripEnvBoundFields(compactionModelEnvBindings), +}); + + export const MODEL_CATALOG_SECTION = 'modelCatalog'; export const ModelCatalogConfigSchema = z.object({ diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 4756af76512..a9fe1fbc365 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -208,6 +208,8 @@ export interface CompactionFinishedEvent { input_cache_read?: number; input_cache_creation?: number; trace_id?: string; + model?: string; + model_display?: string; } export interface CompactionFailedEvent { @@ -676,6 +678,8 @@ export const telemetryEventDefinitions = { input_cache_creation: 'Cache-creation input tokens', trace_id: 'Trace id of the final compaction request round; absent for non-Kimi protocols', + model: 'Model alias that produced the compaction summary (dedicated compaction model when configured, otherwise the active conversation model)', + model_display: 'User-facing model alias for the compaction summary producer', }, }), compaction_failed: defineAgentTelemetryEvent({ diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index f4e48bc0ef3..b5e5aa29b4e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -175,6 +175,7 @@ export * from '#/kosong/protocol/protocolBase'; export * from '#/kosong/protocol/protocolTrait'; import '#/app/kosongConfig/envOverlay'; import '#/app/kosongConfig/visualModelOverlay'; +import '#/app/kosongConfig/compactionModelOverlay'; export * from '#/kosong/model/completionBudget'; export * from '#/kosong/model/hostRequestHeaders'; export * from '#/kosong/model/model'; @@ -190,7 +191,7 @@ export { ModelCatalogConfigSchema, type ModelCatalogConfig, } from '#/app/kosongConfig/configSection'; -export type { SecondaryModelConfig, VisualModelConfig } from '#/app/kosongConfig/configSection'; +export type { SecondaryModelConfig, VisualModelConfig, CompactionModelConfig } from '#/app/kosongConfig/configSection'; export { SECONDARY_MODEL_SECTION, SECONDARY_MODEL_ENV, @@ -202,12 +203,22 @@ export { VISUAL_MODEL_EFFORT_ENV, VisualModelConfigSchema, visualModelEnvBindings, + COMPACTION_MODEL_SECTION, + COMPACTION_MODEL_ENV, + COMPACTION_MODEL_EFFORT_ENV, + CompactionModelConfigSchema, + compactionModelEnvBindings, } from '#/app/kosongConfig/configSection'; export { VISUAL_DERIVED_MODEL_ID, visualModelOverlay, visualModelPatch, } from '#/app/kosongConfig/visualModelOverlay'; +export { + COMPACTION_DERIVED_MODEL_ID, + compactionModelOverlay, + compactionModelPatch, +} from '#/app/kosongConfig/compactionModelOverlay'; export * from '#/app/kosongConfig/kosongConfig'; export * from '#/app/kosongConfig/kosongConfigService'; export * from '#/kosong/model/modelOAuth'; @@ -483,6 +494,8 @@ export * from '#/session/subagent/mirrorAgentRun'; import '#/session/subagent/configSection'; import '#/session/visual/flag'; import '#/session/visual/configSection'; +import '#/session/compaction/flag'; +import '#/session/compaction/configSection'; import '#/session/substitute/flag'; import '#/session/substitute/configSection'; export { @@ -500,6 +513,16 @@ export { VISUAL_MODEL_CHOICE_SCHEMA, type VisualModelChoice, } from '#/session/visual/configSection'; +export { + COMPACTION_MODEL_FLAG_ID, + COMPACTION_MODEL_FLAG_ENV, + compactionModelFlag, +} from '#/session/compaction/flag'; +export { + resolveCompactionModel, + resolveCompactionBinding, + compactionModelBindingFor, +} from '#/session/compaction/configSection'; export { SUBSTITUTE_MODEL_FLAG_ID, SUBSTITUTE_MODEL_FLAG_ENV, diff --git a/packages/agent-core-v2/src/session/compaction/configSection.ts b/packages/agent-core-v2/src/session/compaction/configSection.ts new file mode 100644 index 00000000000..a1933caa29f --- /dev/null +++ b/packages/agent-core-v2/src/session/compaction/configSection.ts @@ -0,0 +1,128 @@ +import type { IConfigService } from '#/app/config/config'; +import type { IFlagService } from '#/app/flag/flag'; +import { + COMPACTION_MODEL_ENV, + COMPACTION_MODEL_SECTION, + type CompactionModelConfig, +} from '#/app/kosongConfig/configSection'; +import { + COMPACTION_DERIVED_MODEL_ID, + compactionModelPatch, +} from '#/app/kosongConfig/compactionModelOverlay'; + +import { COMPACTION_MODEL_FLAG_ID } from './flag'; + +export { COMPACTION_DERIVED_MODEL_ID }; + +/** + * `compaction` domain — compaction-model config-section resolver. + * + * Compaction-model mirror of {@link ../../../session/visual/configSection}: + * resolves which model handles context compaction when the `compaction-model` + * experiment is enabled and `[compaction_model]` is configured. The active + * conversation model remains the default; the compaction model is an opt-in + * override for the summarization/compaction step, parallel to how the visual + * model is an opt-in override for visual inspection tasks. + * + * Resolution rules (mirror of `resolveVisualModel` / `resolveVisualBinding`): + * - When the experiment is disabled, or `[compaction_model]` is unset, returns + * `undefined` from {@link resolveCompactionModel} and the caller's own model + * from {@link resolveCompactionBinding} — no behavior change. + * - When set, {@link resolveCompactionModel} returns the configured recipe; a + * recipe with patch fields binds the synthesized derived entry + * ({@link COMPACTION_DERIVED_MODEL_ID}, materialized by + * `compactionModelOverlay`); a pointer-only recipe binds the pointed entry + * directly. `default_effort` is passed as the explicit compaction thinking + * effort; without it the compaction step resolves thinking naturally (global + * thinking config → the bound model's default effort) rather than inheriting + * the caller's level. + * + * The caller resolves a binding via {@link compactionModelBindingFor}: a helper + * that returns the dedicated compaction model when configured, or the caller's + * own model otherwise. When the dedicated model errors or is inaccessible, the + * caller transparently retries the same round on its own model — the dedicated + * model is a best-effort override, never a hard dependency. Display-facing + * alias resolution goes through {@link compactionDisplayModel}: the derived + * entry id means nothing to a user, so it resolves back to the recipe's base + * alias. + */ +export interface CompactionBinding { + readonly model: string; + readonly thinking?: string; + readonly displayModel: string; +} + +export function resolveCompactionModel( + config: IConfigService, + flags: IFlagService, +): CompactionModelConfig | undefined { + if (!flags.enabled(COMPACTION_MODEL_FLAG_ID)) return undefined; + return config.get(COMPACTION_MODEL_SECTION); +} + +/** + * Resolve which model handles a compaction round. `own` is the caller's current + * model state, used when inheriting (compaction model unset). Returns the + * dedicated compaction model when configured, otherwise the caller's own model. + */ +export function resolveCompactionBinding( + config: IConfigService, + flags: IFlagService, + own: { modelAlias: string; thinkingLevel: string }, +): CompactionBinding { + const compaction = resolveCompactionModel(config, flags); + if (compaction?.model !== undefined) { + const model = + compactionModelPatch(compaction) === undefined + ? compaction.model + : COMPACTION_DERIVED_MODEL_ID; + return { + model, + thinking: compaction.defaultEffort, + displayModel: compactionDisplayModel(config, model), + }; + } + return { + model: own.modelAlias, + thinking: own.thinkingLevel, + displayModel: compactionDisplayModel(config, own.modelAlias), + }; +} + +/** + * Convenience wrapper around {@link resolveCompactionBinding} that fails back to + * the caller's own model when the compaction model is not configured. The + * dedicated model is never a hard requirement: callers treat the returned + * binding as a best-effort override and retry on their own model on error. + */ +export function compactionModelBindingFor( + config: IConfigService, + flags: IFlagService, + own: { modelAlias: string; thinkingLevel: string }, +): CompactionBinding { + return resolveCompactionBinding(config, flags, own); +} + +export function compactionDisplayModel(config: IConfigService, boundAlias: string): string { + if (boundAlias !== COMPACTION_DERIVED_MODEL_ID) return boundAlias; + return ( + config.get(COMPACTION_MODEL_SECTION)?.model ?? boundAlias + ); +} + +/** + * Point a compaction-model resolution failure at `[compaction_model]` when the + * bound model is not the caller's own — otherwise the caller sees a bare + * "model not configured" error with no hint that it comes from the compaction + * model configuration. Used by callers to wrap a dedicated-model error before + * falling back to the current model. + */ +export function wrapCompactionModelError(error: unknown, boundModel: string): unknown { + if (boundModel === COMPACTION_DERIVED_MODEL_ID) { + return new Error( + `Compaction model "${boundModel}" from [compaction_model] / ${COMPACTION_MODEL_ENV} is not a valid [models] entry`, + { cause: error }, + ); + } + return error; +} diff --git a/packages/agent-core-v2/src/session/compaction/flag.ts b/packages/agent-core-v2/src/session/compaction/flag.ts new file mode 100644 index 00000000000..e16498f65a0 --- /dev/null +++ b/packages/agent-core-v2/src/session/compaction/flag.ts @@ -0,0 +1,28 @@ +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +/** + * `compaction` domain — registers the `compaction-model` experimental flag + * into `flag`. + * + * Compaction-model mirror of {@link visualModelFlag}: gates dedicated-model + * selection for context compaction. When this experiment is enabled and + * `[compaction_model]` is configured, the full-compaction routine asks a + * separately configured model to summarize/compact context instead of using + * the active conversation model. When unset, behavior is unchanged (compaction + * inherits the caller's model). If the dedicated model errors or is + * inaccessible, compaction transparently falls back to the current model. + */ +export const COMPACTION_MODEL_FLAG_ID = 'compaction-model'; +export const COMPACTION_MODEL_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_COMPACTION_MODEL'; + +export const compactionModelFlag: FlagDefinitionInput = { + id: COMPACTION_MODEL_FLAG_ID, + title: 'Dedicated model for context compaction', + description: + 'Let context compaction use a separately configured model by default, so a less capable or more expensive conversation model can offload summarization to a dedicated compaction model.', + env: COMPACTION_MODEL_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(compactionModelFlag); diff --git a/packages/agent-core-v2/test/agent/fullCompaction/compaction-model.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/compaction-model.test.ts new file mode 100644 index 00000000000..05ae86d903c --- /dev/null +++ b/packages/agent-core-v2/test/agent/fullCompaction/compaction-model.test.ts @@ -0,0 +1,202 @@ +/** + * `agent/fullCompaction` — dedicated compaction model integration tests. + * + * Exercises the end-to-end wiring of the `[compaction_model]` experiment inside + * `AgentFullCompactionService`: + * - US1: when the flag is on and `[compaction_model]` points at a valid model, + * compaction uses that model (telemetry `model` reflects it). + * - US2: when the dedicated model errors or is inaccessible, compaction + * transparently falls back to the current model on the same round + * (telemetry `model` reflects the current model, the round still completes). + * - US3: when the flag is off or `[compaction_model]` is unset, compaction uses + * the current model with no behavior change (no-regression). + * + * Mirrors the manual-compaction flow from `fullCompaction.test.ts` but drives + * the model selection through the experimental flag + config section. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { APIConnectionError } from '#/kosong/contract/errors'; +import type { Message } from '#/kosong/contract/message'; +import { COMPACTION_MODEL_FLAG_ENV } from '#/session/compaction/flag'; +import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; +import { llmGenerateServices, testAgent, type TestAgentOptions } from '../../harness'; + +type GenerateFn = NonNullable; + +const PROVIDER = { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test/v1', + model: 'kimi-code', +} as const; + +const MODEL_CAPABILITIES = { + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 256_000, +} as const; + +const DEDICATED_MODEL = { + provider: 'test-provider', + model: 'compaction-model', + maxContextSize: 256_000, + capabilities: ['thinking', 'tool_use'], +} as const; + +function compactionFinished(records: readonly TelemetryRecord[]): TelemetryRecord | undefined { + return records.find((record) => record.event === 'compaction_finished'); +} + +function makeAgent(options: { + readonly initialConfig?: Record; + readonly generate?: GenerateFn; +} = {}) { + const records: TelemetryRecord[] = []; + const ctx = testAgent( + ...(options.generate !== undefined ? [llmGenerateServices(options.generate)] : []), + { + telemetry: recordingTelemetry(records), + initialConfig: { + providers: {}, + models: { + 'kimi/compaction': DEDICATED_MODEL, + }, + ...options.initialConfig, + }, + }, + ); + ctx.configure({ provider: PROVIDER, modelCapabilities: MODEL_CAPABILITIES }); + return { ctx, records }; +} + +function seedHistory(ctx: ReturnType['ctx']): void { + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.appendExchange(2, 'old user two', 'old assistant two', 40); + ctx.appendExchange(3, 'recent user three', 'recent assistant three', 120); +} + +async function runManualCompaction( + ctx: ReturnType['ctx'], + records: readonly TelemetryRecord[], + text = 'Compacted summary.', +): Promise { + const completed = ctx.once('compaction.completed'); + ctx.mockNextResponse({ type: 'text', text }); + await ctx.rpc.beginCompaction({ instruction: 'Keep the important test facts.' }); + await Promise.race([ + completed, + new Promise((_, reject) => + setTimeout( + () => { + reject( + new Error( + `timeout; events=${JSON.stringify(records.map((r) => r.event))}`, + ), + ); + }, + 8000, + ), + ), + ]); +} + +describe('FullCompaction — dedicated compaction model', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('uses the dedicated model when the flag is on and [compaction_model] is set', async () => { + vi.stubEnv(COMPACTION_MODEL_FLAG_ENV, 'true'); + const { ctx, records } = makeAgent({ initialConfig: { compactionModel: { model: 'kimi/compaction' } } }); + seedHistory(ctx); + + await runManualCompaction(ctx, records); + + const finished = compactionFinished(records); + expect(finished).toBeDefined(); + expect(finished?.properties?.['model']).toBe('kimi/compaction'); + expect(finished?.properties?.['model_display']).toBe('kimi/compaction'); + }); + + it('falls back to the current model when the dedicated model is inaccessible', async () => { + vi.stubEnv(COMPACTION_MODEL_FLAG_ENV, 'true'); + const { ctx, records } = makeAgent({ + initialConfig: { compactionModel: { model: 'kimi/ghost' } }, + }); + seedHistory(ctx); + + await runManualCompaction(ctx, records); + + const finished = compactionFinished(records); + expect(finished).toBeDefined(); + expect(finished?.properties?.['model']).toBe('kimi-code'); + }); + + it('falls back to the current model when the dedicated model errors on the first call', async () => { + vi.stubEnv(COMPACTION_MODEL_FLAG_ENV, 'true'); + let callCount = 0; + const generate: GenerateFn = async (_chat, _systemPrompt, _tools, _history, _callbacks, options) => { + options?.signal?.throwIfAborted(); + callCount += 1; + if (callCount === 1) { + throw new APIConnectionError('simulated connection failure'); + } + const message: Message = { + role: 'assistant', + content: [{ type: 'text', text: 'Compacted summary.' }], + toolCalls: [], + }; + options?.onStreamEnd?.(); + return { + id: 'mock-fallback', + message, + usage: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 }, + finishReason: 'completed', + rawFinishReason: 'stop', + traceId: null, + }; + }; + const { ctx, records } = makeAgent({ + generate, + initialConfig: { compactionModel: { model: 'kimi/compaction' } }, + }); + seedHistory(ctx); + + await runManualCompaction(ctx, records); + + expect(callCount).toBe(2); + const finished = compactionFinished(records); + expect(finished).toBeDefined(); + expect(finished?.properties?.['model']).toBe('kimi-code'); + }); + + it('uses the current model when the flag is off (no behavior change)', async () => { + const { ctx, records } = makeAgent({ + initialConfig: { compactionModel: { model: 'kimi/compaction' } }, + }); + seedHistory(ctx); + + await runManualCompaction(ctx, records); + + const finished = compactionFinished(records); + expect(finished).toBeDefined(); + expect(finished?.properties?.['model']).toBe('kimi-code'); + }); + + it('uses the current model when the flag is on but [compaction_model] is unset', async () => { + vi.stubEnv(COMPACTION_MODEL_FLAG_ENV, 'true'); + const { ctx, records } = makeAgent(); + seedHistory(ctx); + + await runManualCompaction(ctx, records); + + const finished = compactionFinished(records); + expect(finished).toBeDefined(); + expect(finished?.properties?.['model']).toBe('kimi-code'); + }); +}); diff --git a/packages/agent-core-v2/test/session/compaction/configSection.test.ts b/packages/agent-core-v2/test/session/compaction/configSection.test.ts new file mode 100644 index 00000000000..0f48e15c422 --- /dev/null +++ b/packages/agent-core-v2/test/session/compaction/configSection.test.ts @@ -0,0 +1,166 @@ +/** + * `session/compaction` resolver tests — covers `resolveCompactionModel`, + * `resolveCompactionBinding`, `compactionModelBindingFor`, `compactionDisplayModel`, + * and `wrapCompactionModelError`, including the unset-fallback path. + * + * Mirror of the `session/visual` resolver tests: the compaction model is an + * opt-in override for the compaction step, parallel to how the visual model is + * an opt-in override for visual inspection. Uses the StubConfigService + + * stubFlag helpers. + */ + +import { describe, expect, it } from 'vitest'; + +import { COMPACTION_MODEL_SECTION } from '#/app/kosongConfig/configSection'; +import { COMPACTION_DERIVED_MODEL_ID } from '#/app/kosongConfig/compactionModelOverlay'; +import { + compactionDisplayModel, + compactionModelBindingFor, + resolveCompactionBinding, + resolveCompactionModel, + wrapCompactionModelError, +} from '#/session/compaction/configSection'; +import { COMPACTION_MODEL_FLAG_ID } from '#/session/compaction/flag'; +import { Error2, ErrorCodes } from '#/errors'; + +import { stubFlag } from '../../app/flag/stubs'; +import { StubConfigService } from '../../kosong/stubs'; + +function makeServices(configValues: Record, flagEnabled = true) { + const config = new StubConfigService(configValues); + const flags = stubFlag((id) => flagEnabled && id === COMPACTION_MODEL_FLAG_ID); + return { config, flags }; +} + +const own = { modelAlias: 'caller/kimi-coder', thinkingLevel: 'medium' }; + +describe('resolveCompactionModel', () => { + it('returns undefined when the compaction-model flag is disabled', () => { + const { config } = makeServices({ [COMPACTION_MODEL_SECTION]: { model: 'kimi/compaction' } }, false); + const { flags } = makeServices({}, false); + expect(resolveCompactionModel(config, flags)).toBeUndefined(); + }); + + it('returns undefined when [compaction_model] is unset (no behavior change)', () => { + const { config, flags } = makeServices({}); + expect(resolveCompactionModel(config, flags)).toBeUndefined(); + }); + + it('returns the configured recipe when set and the flag is on', () => { + const { config, flags } = makeServices({ + [COMPACTION_MODEL_SECTION]: { model: 'kimi/compaction', defaultEffort: 'low' }, + }); + expect(resolveCompactionModel(config, flags)).toEqual({ + model: 'kimi/compaction', + defaultEffort: 'low', + }); + }); +}); + +describe('resolveCompactionBinding', () => { + it('inherits the caller model when compaction model is unset (no behavior change)', () => { + const { config, flags } = makeServices({}); + expect(resolveCompactionBinding(config, flags, own)).toEqual({ + model: own.modelAlias, + thinking: own.thinkingLevel, + displayModel: own.modelAlias, + }); + }); + + it('inherits the caller model when the flag is disabled even if the recipe is set', () => { + const { config } = makeServices({ [COMPACTION_MODEL_SECTION]: { model: 'kimi/compaction' } }); + const { flags } = makeServices({}, false); + expect(resolveCompactionBinding(config, flags, own)).toEqual({ + model: own.modelAlias, + thinking: own.thinkingLevel, + displayModel: own.modelAlias, + }); + }); + + it('binds the compaction model when set (pointer-only recipe)', () => { + const { config, flags } = makeServices({ + [COMPACTION_MODEL_SECTION]: { model: 'kimi/compaction' }, + }); + expect(resolveCompactionBinding(config, flags, own)).toEqual({ + model: 'kimi/compaction', + thinking: undefined, + displayModel: 'kimi/compaction', + }); + }); + + it('binds the derived entry when the recipe carries patch fields', () => { + const { config, flags } = makeServices({ + [COMPACTION_MODEL_SECTION]: { model: 'kimi/compaction', defaultEffort: 'low', maxOutputSize: 4096 }, + }); + const binding = resolveCompactionBinding(config, flags, own); + expect(binding.model).toBe(COMPACTION_DERIVED_MODEL_ID); + expect(binding.thinking).toBe('low'); + // displayModel resolves the derived id back to the recipe's base alias + expect(binding.displayModel).toBe('kimi/compaction'); + }); +}); + +describe('compactionModelBindingFor', () => { + it('mirrors resolveCompactionBinding (inherits caller when unset)', () => { + const { config, flags } = makeServices({}); + expect(compactionModelBindingFor(config, flags, own)).toEqual({ + model: own.modelAlias, + thinking: own.thinkingLevel, + displayModel: own.modelAlias, + }); + }); + + it('binds the compaction model when set (pointer-only recipe)', () => { + const { config, flags } = makeServices({ + [COMPACTION_MODEL_SECTION]: { model: 'kimi/compaction' }, + }); + expect(compactionModelBindingFor(config, flags, own)).toEqual({ + model: 'kimi/compaction', + thinking: undefined, + displayModel: 'kimi/compaction', + }); + }); +}); + +describe('compactionDisplayModel', () => { + it('passes through any non-derived alias', () => { + const { config } = makeServices({}); + expect(compactionDisplayModel(config, 'kimi/compaction')).toBe('kimi/compaction'); + }); + + it('resolves the derived id back to the recipe base alias', () => { + const { config } = makeServices({ + [COMPACTION_MODEL_SECTION]: { model: 'kimi/compaction' }, + }); + expect(compactionDisplayModel(config, COMPACTION_DERIVED_MODEL_ID)).toBe('kimi/compaction'); + }); + + it('falls back to the derived id when the recipe has been removed', () => { + const { config } = makeServices({}); + expect(compactionDisplayModel(config, COMPACTION_DERIVED_MODEL_ID)).toBe(COMPACTION_DERIVED_MODEL_ID); + }); +}); + +describe('wrapCompactionModelError', () => { + const callerModelAlias = 'caller/kimi-coder'; + + it('returns the error unchanged when the bound model is the caller own', () => { + const error = new Error('boom'); + expect(wrapCompactionModelError(error, callerModelAlias)).toBe(error); + }); + + it('returns the error unchanged when the bound model is a pointer-only alias', () => { + const error = new Error('boom'); + expect(wrapCompactionModelError(error, 'kimi/compaction')).toBe(error); + }); + + it('wraps a failure with a hint pointing at [compaction_model] for the derived id', () => { + const error = new Error2(ErrorCodes.CONFIG_INVALID, 'Model "kimi/compaction" is not configured.', { + details: { model: 'kimi/compaction' }, + }); + const wrapped = wrapCompactionModelError(error, COMPACTION_DERIVED_MODEL_ID) as Error; + expect(wrapped).toBeInstanceOf(Error); + expect(wrapped.message).toContain(COMPACTION_DERIVED_MODEL_ID); + expect(wrapped.message).toContain('[compaction_model]'); + }); +});