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]'); + }); +});