Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/warn-malformed-models-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Warn at startup when a [models] entry in config.toml is missing the model field and cannot be used.
4 changes: 4 additions & 0 deletions packages/agent-core-v2/src/app/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface ConfigKeyDeprecation {
readonly message?: string;
}

export type ConfigCollectDiagnostics = (rawSection: unknown) => readonly ConfigDiagnostic[];

export type EnvBindings<T> = EnvBinding | { [K in keyof T]?: EnvBinding | EnvBindings<T[K]> };

export type AnyEnvBindings = EnvBinding | { readonly [key: string]: EnvBinding | AnyEnvBindings };
Expand Down Expand Up @@ -93,6 +95,7 @@ export interface ConfigSection<T = unknown> {
readonly fromToml?: ConfigFromToml;
readonly toToml?: ConfigToToml;
readonly deprecations?: readonly ConfigKeyDeprecation[];
readonly collectDiagnostics?: ConfigCollectDiagnostics;
}

export interface RegisterSectionOptions<T> {
Expand All @@ -104,6 +107,7 @@ export interface RegisterSectionOptions<T> {
readonly fromToml?: ConfigFromToml;
readonly toToml?: ConfigToToml;
readonly deprecations?: readonly ConfigKeyDeprecation[];
readonly collectDiagnostics?: ConfigCollectDiagnostics;
}

export interface ConfigEffectiveOverlay {
Expand Down
11 changes: 10 additions & 1 deletion packages/agent-core-v2/src/app/config/configService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ function isSameSection(
existing.fromToml === options.fromToml &&
existing.toToml === options.toToml &&
deepEqual(existing.defaultValue, options.defaultValue) &&
deepEqual(existing.deprecations, options.deprecations)
deepEqual(existing.deprecations, options.deprecations) &&
existing.collectDiagnostics === options.collectDiagnostics
);
}

Expand Down Expand Up @@ -249,6 +250,7 @@ export class ConfigRegistry extends Disposable implements IConfigRegistry {
fromToml: options.fromToml,
toToml: options.toToml,
deprecations: options.deprecations,
collectDiagnostics: options.collectDiagnostics,
});
this._onDidRegisterSection.fire({ domain });
}
Expand Down Expand Up @@ -576,6 +578,13 @@ export class ConfigService extends Disposable implements IConfigService {
for (const diagnostic of collectKeyDeprecations(nextRawSnake, this.registry.listSections())) {
this.pushDiagnostic(diagnostic);
}
for (const section of this.registry.listSections()) {
if (section.collectDiagnostics === undefined) continue;
const rawSection = nextRawSnake[camelToSnake(section.domain)];
for (const diagnostic of section.collectDiagnostics(rawSection)) {
this.pushDiagnostic(diagnostic);
}
}
if (source !== 'load' && JSON.stringify(nextRawSnake) === JSON.stringify(this.rawSnake)) {
const scratch = { ...this.validated };
this.applySectionEnvBindings(scratch, true);
Expand Down
50 changes: 50 additions & 0 deletions packages/agent-core-v2/src/app/kosongConfig/configSection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from 'zod';

import {
type ConfigDiagnostic,
type ConfigStripEnv,
envBindings,
} from '#/app/config/config';
Expand Down Expand Up @@ -195,6 +196,54 @@ type _AssertModelsSection = AssertExact<
Equal<z.infer<typeof ModelsSectionSchema>, ModelsSection>
>;

const MODEL_OBJECT_FIELDS = new Set(
Object.entries(ModelRecordSchema.shape)
.filter(([, field]) => unwrapWrapperSchema(field as z.ZodTypeAny) instanceof z.ZodObject)
.map(([key]) => camelToSnake(key)),
);

function unwrapWrapperSchema(schema: z.ZodTypeAny): z.ZodTypeAny {
let current = schema;
while (
current instanceof z.ZodOptional ||
current instanceof z.ZodNullable ||
current instanceof z.ZodDefault
) {
current = current.unwrap() as z.ZodTypeAny;
}
return current;
}

function collectMalformedModelEntries(rawModels: unknown): ConfigDiagnostic[] {
if (!isPlainObject(rawModels)) return [];
const diagnostics: ConfigDiagnostic[] = [];
for (const [alias, entry] of Object.entries(rawModels)) {
if (!isPlainObject(entry)) continue;
if (entry['model'] !== undefined || entry['name'] !== undefined) continue;
diagnostics.push({
domain: MODELS_SECTION,
severity: 'warning',
message: malformedModelMessage(alias, entry),
});
}
return diagnostics;
}

function malformedModelMessage(alias: string, entry: Record<string, unknown>): string {
const base = `[models] entry '${alias}' is missing the 'model' field and cannot be used as a model`;
const dottedAlias = dottedAliasSuffix(alias, entry);
if (dottedAlias === undefined) return `${base}.`;
return `${base}; if the alias contains dots, quote the table name (e.g. [models."${dottedAlias}"]).`;
}

function dottedAliasSuffix(alias: string, entry: Record<string, unknown>): string | undefined {
for (const [key, value] of Object.entries(entry)) {
if (MODEL_OBJECT_FIELDS.has(key) || !isPlainObject(value)) continue;
return dottedAliasSuffix(`${alias}.${key}`, value) ?? `${alias}.${key}`;
}
return undefined;
}

export const modelsFromToml = (rawSnake: unknown): unknown => {
if (!isPlainObject(rawSnake)) return rawSnake;
const out: Record<string, unknown> = {};
Expand Down Expand Up @@ -255,6 +304,7 @@ registerConfigSection(MODELS_SECTION, ModelsSectionSchema, {
defaultValue: {},
fromToml: modelsFromToml,
toToml: modelsToToml,
collectDiagnostics: collectMalformedModelEntries,
});

export const THINKING_SECTION = 'thinking';
Expand Down
100 changes: 96 additions & 4 deletions packages/agent-core-v2/test/app/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,98 @@ describe('config deprecations', () => {
});
});

describe('malformed models config entries', () => {
async function createConfig(toml: string) {
const disposables = new DisposableStore();
const ix = disposables.add(new TestInstantiationService());
const storage = new InMemoryStorageService();
await storage.write('', 'config.toml', new TextEncoder().encode(toml));
ix.stub(ILogService, stubLog());
ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', {}));
ix.stub(IFileSystemStorageService, storage);
ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore));
ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry));
ix.set(IConfigService, new SyncDescriptor(ConfigService));
const config = ix.get(IConfigService);
await config.ready;
return { config, disposables, storage };
}

it('warns at load time when a dotted alias parses as a nested table', async () => {
const { config, disposables } = await createConfig(
'[models.kimi-k2.7-code]\nmodel = "kimi-k2.7-code"\nmax_context_size = 262144\n',
);

expect(config.diagnostics()).toContainEqual({
domain: 'models',
severity: 'warning',
message:
"[models] entry 'kimi-k2' is missing the 'model' field and cannot be used as a model; " +
'if the alias contains dots, quote the table name (e.g. [models."kimi-k2.7-code"]).',
});

disposables.dispose();
});

it('stays silent for quoted dotted aliases and entries with a wire-facing name', async () => {
const { config, disposables } = await createConfig(
'[models."kimi-k2.7-code"]\nmodel = "kimi-k2.7-code"\n\n[models.renamed]\nname = "wire-name"\n',
);

expect(config.diagnostics()).toEqual([]);

disposables.dispose();
});

it('warns without the dotted-alias hint when the entry has no nested table', async () => {
const { config, disposables } = await createConfig(
'[models.partial]\nmax_context_size = 262144\n',
);

expect(config.diagnostics()).toContainEqual({
domain: 'models',
severity: 'warning',
message:
"[models] entry 'partial' is missing the 'model' field and cannot be used as a model.",
});

disposables.dispose();
});

it('does not mistake schema object fields for a dotted alias', async () => {
const { config, disposables } = await createConfig(
'[models.partial]\noverrides = { max_output_size = 8192 }\n',
);

expect(config.diagnostics()).toContainEqual({
domain: 'models',
severity: 'warning',
message:
"[models] entry 'partial' is missing the 'model' field and cannot be used as a model.",
});

disposables.dispose();
});

it('clears the warning on reload once the entry is fixed', async () => {
const { config, disposables, storage } = await createConfig(
'[models.kimi-k2.7-code]\nmodel = "kimi-k2.7-code"\n',
);
expect(config.diagnostics()).toHaveLength(1);

await storage.write(
'',
'config.toml',
new TextEncoder().encode('[models."kimi-k2.7-code"]\nmodel = "kimi-k2.7-code"\n'),
);
await config.reload();

expect(config.diagnostics()).toEqual([]);

disposables.dispose();
});
});

describe('task config section', () => {
it('re-applies the keepAliveOnExit env binding on every get()', async () => {
const env: Record<string, string> = {};
Expand Down Expand Up @@ -2445,7 +2537,7 @@ describe('config section collection fold (D12)', () => {
parse(value: unknown): RuntimeFoldDemo {
const demo = value as RuntimeFoldDemo;
if (typeof demo?.enabled !== 'boolean') {
throw new Error('runtimeFoldDemo.enabled must be a boolean');
throw new TypeError('runtimeFoldDemo.enabled must be a boolean');
}
return demo;
},
Expand Down Expand Up @@ -2731,8 +2823,8 @@ describe('ConfigService replaceSections', () => {
defaultModel: undefined,
thinking: {},
});
expect([...domains].sort()).toEqual(
[PROVIDERS_SECTION, MODELS_SECTION, DEFAULT_MODEL_SECTION, THINKING_SECTION].sort(),
expect([...domains].toSorted()).toEqual(
[PROVIDERS_SECTION, MODELS_SECTION, DEFAULT_MODEL_SECTION, THINKING_SECTION].toSorted(),
);

disposables.dispose();
Expand Down Expand Up @@ -2810,7 +2902,7 @@ describe('ConfigService persistence guards', () => {
async function expectPersistBlocked(promise: Promise<unknown>): Promise<void> {
const error = await promise.then(
() => undefined,
(e: unknown) => e,
(error: unknown) => error,
);
expect(isError2(error)).toBe(true);
expect((error as Error2).code).toBe(ErrorCodes.CONFIG_PERSIST_BLOCKED);
Expand Down
Loading