diff --git a/.changeset/media-registrar-stale-alias.md b/.changeset/media-registrar-stale-alias.md new file mode 100644 index 000000000..692785e5b --- /dev/null +++ b/.changeset/media-registrar-stale-alias.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix an `[unexpected] Error2: Model "" is not configured in config.toml` error printed on startup when a restored session references a model that is no longer configured (e.g. after logging out of the managed Kimi Code account). Media tool registration now degrades gracefully instead of throwing from the `agent.status.updated` listener. diff --git a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts index 15f7df2db..90818fa8d 100644 --- a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts +++ b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts @@ -17,6 +17,14 @@ * converts `video_url` takes the inline fallback when no upload hook * exists. * + * The alias re-resolved on every refresh comes from persisted profile + * state that resume replays without catalog validation, so it may no + * longer resolve (e.g. its config.toml entry was removed on logout). + * A failed resolution degrades to "no model" — registration proceeds + * from the profile-reported capabilities without a model-bound video + * uploader — instead of throwing out of the event listener, where the + * escape would surface as an `[unexpected]` error. + * * The plain-data state (`registeredKey`) is registered into `agentState` * (`IAgentStateService`) and read/written through it; `registration` stays an * instance field (the live `IDisposable` tool-registration handle, not plain @@ -125,8 +133,13 @@ export class AgentMediaToolsRegistrar extends Service implements IAgentMediaTool let requester: ModelRequester | undefined; let model: Model | undefined; if (modelAlias !== '') { - requester = this.modelCatalog.getRequester(modelAlias); - model = requester.model; + try { + requester = this.modelCatalog.getRequester(modelAlias); + model = requester.model; + } catch { + requester = undefined; + model = undefined; + } } this.registration = registerMediaTools(this.toolRegistry, { runtime, diff --git a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts index 7f256580f..14ddd1575 100644 --- a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts +++ b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts @@ -9,13 +9,17 @@ import * as posixPath from 'node:path/posix'; -import type { ModelCapability } from '#/kosong/contract/capability'; +import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/kosong/contract/capability'; import type { ContentPart } from '#/kosong/contract/message'; import { VideoUploadUnsupportedError } from '#/kosong/contract/errors'; import { Jimp } from 'jimp'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Emitter } from '#/_base/event'; +import { + resetUnexpectedErrorHandler, + setUnexpectedErrorHandler, +} from '#/_base/errors/unexpectedError'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; @@ -873,10 +877,14 @@ describe('AgentMediaToolsRegistrar', () => { getModelCapabilities: () => state.capabilities, getModel: () => state.alias, } as unknown as IAgentProfileService; + const brokenAliases = new Set(); const modelCatalog = { - getRequester: (id: string) => ({ - model: { id, name: id, providerName: 'test', protocol: 'openai' }, - }), + getRequester: (id: string) => { + if (brokenAliases.has(id)) { + throw new Error(`Model "${id}" is not configured in config.toml.`); + } + return { model: { id, name: id, providerName: 'test', protocol: 'openai' } }; + }, } as unknown as IModelCatalog; const workspaceCtx = { workDir: '/workspace', @@ -916,7 +924,13 @@ describe('AgentMediaToolsRegistrar', () => { runtimeAvailable = available; runtimeChanges.fire(); }; - return { registry, registrar, bindModel, setRuntimeAvailable }; + const breakAlias = (alias: string): void => { + brokenAliases.add(alias); + }; + const healAlias = (alias: string): void => { + brokenAliases.delete(alias); + }; + return { registry, registrar, bindModel, setRuntimeAvailable, breakAlias, healAlias }; } it('registers nothing until a media-capable model binds, then registers ReadMediaFile', () => { @@ -970,6 +984,25 @@ describe('AgentMediaToolsRegistrar', () => { expect(registry.resolve('ReadMediaFile')).toBe(first); }); + it('survives an unconfigured bound alias and recovers when it resolves again', () => { + const unexpected: unknown[] = []; + setUnexpectedErrorHandler((err) => unexpected.push(err)); + try { + const { registry, bindModel, breakAlias, healAlias } = createRegistrarHarness(); + breakAlias('stale-model'); + bindModel('stale-model', UNKNOWN_CAPABILITY); + expect(unexpected).toHaveLength(0); + expect(registry.resolve('ReadMediaFile')).toBeUndefined(); + + healAlias('stale-model'); + bindModel('stale-model', capabilities({ image_in: true, video_in: true })); + expect(registry.resolve('ReadMediaFile')).toBeInstanceOf(ReadMediaFileTool); + expect(unexpected).toHaveLength(0); + } finally { + resetUnexpectedErrorHandler(); + } + }); + it('unregisters on dispose', () => { const { registry, registrar, bindModel } = createRegistrarHarness(); bindModel('vision-model', capabilities({ image_in: true, video_in: true }));