Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/catalog-followup-correctness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@moonshot-ai/kosong": patch
"@moonshot-ai/kimi-code-sdk": patch
"@moonshot-ai/agent-core": patch
"@moonshot-ai/agent-core-v2": patch
"@moonshot-ai/kimi-code": patch
---

Fix a set of small correctness issues on top of the catalog metadata work: a configured `effort = "OFF"` is now normalized instead of being sent upstream as an invalid effort; a model's declared input limit can no longer exceed its effective context window, and the clamp now copies the record instead of mutating the user's config in place; context-usage percentages are consistent across all status endpoints and clamped to 1, with the default-model fallback also using the input cap; a provider-observed smaller context window now actually wins over the catalog's declared input cap during overflow recovery; per-model endpoints declared with an unrecognized override SDK are preserved via the OpenAI-compatible fallback, while known proprietary SDKs stay refused; and the model inspector attributes input-limit fields to their actual config, override, or clamp provenance.
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,13 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull

private resolveModelContextWithEffectiveMax(): ProfileModelContext {
const resolved = this.profile.resolveModelContext();
const effectiveMax = this.getEffectiveMaxContextTokens();
return {
...resolved,
modelCapabilities: {
...resolved.modelCapabilities,
max_context_tokens: this.getEffectiveMaxContextTokens(),
max_context_tokens: effectiveMax,
max_input_tokens: effectiveMax,
},
};
}
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core-v2/src/agent/profile/profileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
thinkingEffort: includeThinkingEffort
? this.getEffectiveThinkingLevel()
: undefined,
maxContextTokens: this.getModelCapabilities().max_context_tokens,
maxContextTokens:
this.getModelCapabilities().max_input_tokens ??
this.getModelCapabilities().max_context_tokens,
Comment thread
RealKai42 marked this conversation as resolved.
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,14 @@ export class SessionLegacyService implements ISessionLegacyService {
const swarm = agent.accessor.get(IAgentSwarmService);

const model = profile.getModel();
const caps = profile.getModelCapabilities() as { max_context_tokens?: number };
const caps = profile.getModelCapabilities() as {
max_context_tokens?: number;
max_input_tokens?: number;
};
const maxTokens =
model === '' ? resolveDefaultModelContextTokens(agent) : (caps.max_context_tokens ?? 0);
model === ''
? resolveDefaultModelContextTokens(agent)
Comment thread
RealKai42 marked this conversation as resolved.
: (caps.max_input_tokens ?? caps.max_context_tokens ?? 0);
Comment thread
RealKai42 marked this conversation as resolved.
const tokens = contextSize.get().size;
const planData = await plan.status();

Expand All @@ -175,7 +180,7 @@ export class SessionLegacyService implements ISessionLegacyService {
swarm_mode: swarm.isActive,
context_tokens: tokens,
max_context_tokens: maxTokens,
context_usage: maxTokens > 0 ? tokens / maxTokens : 0,
context_usage: maxTokens > 0 ? Math.min(1, tokens / maxTokens) : 0,
};
}

Expand Down Expand Up @@ -204,7 +209,8 @@ function resolveDefaultModelContextTokens(agent: IAgentScopeHandle): number {
const defaultModel = agent.accessor.get(IConfigService).get<string>('defaultModel');
if (typeof defaultModel !== 'string' || defaultModel.length === 0) return 0;
try {
return agent.accessor.get(IModelCatalog).get(defaultModel).capabilities.max_context_tokens;
const capabilities = agent.accessor.get(IModelCatalog).get(defaultModel).capabilities;
return capabilities.max_input_tokens ?? capabilities.max_context_tokens;
} catch {
return 0;
}
Expand Down
22 changes: 22 additions & 0 deletions packages/agent-core-v2/src/kosong/model/inspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,20 @@ export function attributeEffectiveFields(
const before = (base as Record<string, unknown>)[key];
const after = (effective as Record<string, unknown>)[key];
if (before === undefined && after === undefined) continue;
if (key === 'maxInputSize') {
const rawValue = (overridden.has(key) ? overrides?.[key] : before) as number | undefined;
if (
rawValue !== undefined &&
effective.maxContextSize !== undefined &&
rawValue > effective.maxContextSize
) {
trace.record(path, {
kind: 'synthesized',
detail: 'clamped to the effective max_context_size',
});
continue;
}
}
if (overridden.has(key)) {
trace.record(path, { kind: 'override', detail: 'models.*.overrides' });
continue;
Expand Down Expand Up @@ -486,6 +500,14 @@ function attributeCapabilities(
kind: 'synthesized',
detail: 'forced to the resolved maxContextSize',
});
const maxInputSource = sources.get('model.effective.maxInputSize');
sources.set(
'resolved.capabilities.max_input_tokens',
Comment thread
RealKai42 marked this conversation as resolved.
maxInputSource ?? {
kind: 'none',
detail: 'no declared input limit — the total window applies',
},
);
}

function attributeHeaders(
Expand Down
8 changes: 7 additions & 1 deletion packages/agent-core-v2/src/kosong/model/modelAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,13 @@ export function effectiveModelConfig(
) {
delete effective.defaultEffort;
}
return withAnthropicProfile(effective, providerType);
const clamped =
effective.maxInputSize !== undefined &&
effective.maxContextSize !== undefined &&
effective.maxInputSize > effective.maxContextSize
? { ...effective, maxInputSize: effective.maxContextSize }
: effective;
return withAnthropicProfile(clamped, providerType);
}

function withAnthropicProfile(model: ModelRecord, providerType?: string): ModelRecord {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/kosong/model/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export function resolveThinkingEffortForModel(
model: ModelThinkingMetadata | undefined,
strictValidation = false,
): ThinkingEffort {
const configured = nonEmpty(defaults?.effort) as ThinkingEffort | undefined;
const configured = normalizeRequestedThinkingEffort(defaults?.effort);
const normalized = normalizeRequestedThinkingEffort(requested);
let effort: ThinkingEffort;
if (normalized !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2217,6 +2217,66 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});

it('honors the observed provider window over a declared input cap', async () => {
let callCount = 0;
const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => {
callCount += 1;
if (callCount === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-observed-window');
}
if (callCount === 2) {
return textResult('Observed recovery summary.');
}
if (callCount === 3) {
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered after observed overflow.',
});
return textResult('Recovered after observed overflow.');
}
if (callCount === 4) {
return textResult('Observed preemptive summary.');
}
if (callCount === 5) {
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Answered after observed-window precompaction.',
});
return textResult('Answered after observed-window precompaction.');
}
throw new Error(`Unexpected generate call ${String(callCount)}`);
};
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 200_000,
max_input_tokens: 150_000,
},
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();

await ctx.rpc.prompt({ input: [{ type: 'text', text: 'learn observed window' }] });
await ctx.untilTurnEnd();
expect(callCount).toBe(3);

ctx.appendExchange(2, 'near observed user', 'near observed assistant', 120_000);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'use observed window' }] });
const events = await ctx.untilTurnEnd();

// Without the observed cap being written into max_input_tokens, the
// strategy would keep sizing against the raw 150k input cap and never
// preemptively compact the 120k context the provider already rejected.
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
expect(callCount).toBe(5);
expect(eventIndex(events, 'compaction.started')).toBeLessThan(
eventIndex(events, 'turn.step.started'),
);
});

it('recovers from plain 413 when estimated request is over effective max', async () => {
let callCount = 0;
const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => {
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-core-v2/test/agent/profile/thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ describe('resolveThinkingEffortForModel', () => {
expect(resolveThinkingEffortForModel('off', undefined, alwaysThinkingModel)).toBe('on');
});

it('normalizes a configured off value (case/whitespace) instead of sending it upstream', () => {
expect(resolveThinkingEffortForModel(undefined, { effort: ' OFF ' }, effortModel)).toBe('off');
expect(resolveThinkingEffortForModel(undefined, { effort: 'Off' }, booleanModel)).toBe('off');
expect(
resolveThinkingEffortForModel(undefined, { enabled: false, effort: ' OFF ' }, alwaysThinkingEffortModel),
).toBe('high');
});

it('treats a configured off as absent when clamping always-thinking models', () => {
expect(resolveThinkingEffortForModel(undefined, { effort: 'off' }, alwaysThinkingEffortModel)).toBe(
'high',
Expand Down
25 changes: 25 additions & 0 deletions packages/agent-core-v2/test/app/model/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,31 @@ import '#/kosong/provider/providers/kimi/kimi.contrib';
import '#/kosong/provider/providers/standard.contrib';

describe('effectiveModelConfig', () => {
it('clamps the input cap to the effective total window without mutating the source', () => {
const record = {
provider: 'custom',
model: 'gpt-5',
maxContextSize: 128000,
maxInputSize: 272000,
};

const effective = effectiveModelConfig(record);
expect(effective.maxInputSize).toBe(128000);
expect(record.maxInputSize).toBe(272000);

const withOverrides = {
provider: 'custom',
model: 'gpt-5',
maxContextSize: 400000,
maxInputSize: 272000,
overrides: { maxContextSize: 128000 },
};
const effectiveOverride = effectiveModelConfig(withOverrides);
expect(effectiveOverride.maxContextSize).toBe(128000);
expect(effectiveOverride.maxInputSize).toBe(128000);
expect(withOverrides.maxInputSize).toBe(272000);
});

it('derives the official effort metadata from a Claude model name', () => {
expect(
effectiveModelConfig({
Expand Down
70 changes: 70 additions & 0 deletions packages/agent-core-v2/test/kosong/model/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,9 @@ describe('ModelCatalog inspect', () => {
expect(view.sources['resolved.capabilities.max_context_tokens']).toMatchObject({
kind: 'synthesized',
});
expect(view.sources['resolved.capabilities.max_input_tokens']).toMatchObject({
kind: 'none',
});
expect(view.sources['resolved']).toMatchObject({ kind: 'synthesized' });
// Kimi's definition capability is UNKNOWN — nothing is detected.
expect(view.sources['resolved.capabilities.tool_use']).toMatchObject({ kind: 'none' });
Expand Down Expand Up @@ -586,6 +589,73 @@ describe('ModelCatalog inspect', () => {
}
});

it('attributes the input cap to config, its clamp, and its absence', () => {
const { host, catalog } = createHost({
providers: {
kimi: { type: 'kimi', apiKey: 'sk', baseUrl: 'https://api.example.test/v1' },
},
models: {
declared: {
provider: 'kimi',
model: 'kimi-k2',
maxContextSize: 400000,
maxInputSize: 272000,
},
clamped: {
provider: 'kimi',
model: 'kimi-k2',
maxContextSize: 400000,
maxInputSize: 272000,
overrides: { maxContextSize: 128000 },
},
clampedOverride: {
provider: 'kimi',
model: 'kimi-k2',
maxContextSize: 400000,
overrides: { maxContextSize: 128000, maxInputSize: 272000 },
},
plain: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 100 },
},
});
try {
const declaredView = catalog.inspect('declared');
expect(declaredView.resolved.maxInputSize).toBe(272000);
expect(declaredView.sources['model.effective.maxInputSize']).toMatchObject({ kind: 'config' });
expect(declaredView.sources['resolved.capabilities.max_input_tokens']).toMatchObject({
kind: 'config',
});

const clampedView = catalog.inspect('clamped');
expect(clampedView.resolved.maxInputSize).toBe(128000);
expect(clampedView.sources['model.effective.maxInputSize']).toMatchObject({
kind: 'synthesized',
detail: expect.stringContaining('clamped'),
});
expect(clampedView.sources['resolved.capabilities.max_input_tokens']).toMatchObject({
kind: 'synthesized',
});

// An override value itself above the window is attributed to the clamp,
// not to models.*.overrides.
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
const clampedOverrideView = catalog.inspect('clampedOverride');
expect(clampedOverrideView.resolved.maxInputSize).toBe(128000);
expect(clampedOverrideView.sources['model.effective.maxInputSize']).toMatchObject({
kind: 'synthesized',
detail: expect.stringContaining('clamped'),
});
expect(clampedOverrideView.sources['model.effective.maxInputSize']).not.toMatchObject({
kind: 'override',
});

const plainView = catalog.inspect('plain');
expect(plainView.sources['resolved.capabilities.max_input_tokens']).toMatchObject({
kind: 'none',
});
} finally {
host.dispose();
}
});

it('attributes env-bag credentials and endpoints by env-var name', () => {
const { host, catalog } = createHost({
providers: {
Expand Down
9 changes: 6 additions & 3 deletions packages/agent-core/src/agent/config/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,16 @@ export function resolveThinkingEffort(
kimiProtocol = false,
): ThinkingEffort {
const effectiveModel = model === undefined ? undefined : effectiveModelAlias(model);
// Normalize the configured value once: 'OFF' / ' off ' must be read as off
// on every path, not passed upstream as a concrete effort.
const configured = config?.effort === undefined ? undefined : config.effort.trim().toLowerCase();
let effort: ThinkingEffort;
if (requested !== undefined) {
effort = requested;
} else if (config?.enabled === false) {
effort = 'off';
} else {
effort = config?.effort ?? defaultThinkingEffortFor(effectiveModel);
effort = configured ?? defaultThinkingEffortFor(effectiveModel);
}

if (effort === 'off' && effectiveModel?.capabilities?.includes('always_thinking') === true) {
Expand All @@ -119,8 +122,8 @@ export function resolveThinkingEffort(
// disable, it should not also discard a chosen effort. A configured
// 'off' is treated as absent: the model default applies instead.
effort =
config?.effort !== undefined && config.effort.trim().toLowerCase() !== 'off'
? config.effort
configured !== undefined && configured !== 'off'
? configured
: defaultThinkingEffortFor(effectiveModel);
}

Expand Down
10 changes: 9 additions & 1 deletion packages/agent-core/src/config/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ export function effectiveModelAlias(
delete effective.defaultEffort;
}

return withAnthropicProfile(effective, providerType);
// The input cap can never exceed the effective total window (an override
// lowering max_context_size must not leave a stale, larger cap behind).
// Build a copy for the clamp — never rewrite the caller's config record.
const clamped =
effective.maxInputSize !== undefined && effective.maxInputSize > effective.maxContextSize
? { ...effective, maxInputSize: effective.maxContextSize }
: effective;

return withAnthropicProfile(clamped, providerType);
}

function withAnthropicProfile(model: ModelAlias, providerType?: ProviderType): ModelAlias {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/services/session/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ export class SessionService extends Disposable implements ISessionService {
const capability = config.modelCapabilities;
const maxContextTokens = capability?.max_input_tokens ?? capability?.max_context_tokens ?? 0;
const contextTokens = context.tokenCount;
const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0;
const contextUsage = maxContextTokens > 0 ? Math.min(1, contextTokens / maxContextTokens) : 0;

const agentState = this.promptService.getAgentStateSnapshot(id);

Expand Down
Loading
Loading