fix(agent-core-v2): degrade media tool registration when the bound model alias is stale (#2985)

* fix(agent-core-v2): degrade media tool registration when the bound model alias is stale

A restored session replays its persisted profile.bind without catalog
validation, so the profile can carry a model alias that no longer
resolves (e.g. the managed kimi-code models were removed from
config.toml on logout). AgentMediaToolsRegistrar.refresh() called
modelCatalog.getRequester() unguarded on that alias; the throw escaped
the agent.status.updated listener and was reported as an [unexpected]
Error2 (config.invalid) on startup.

Catch the resolution failure and degrade to "no model": media tools
stay registered off the profile-reported capabilities, just without a
model-bound video uploader, matching the tryResolveRawModel style used
elsewhere in the profile service.

* test(agent-core-v2): reproduce the stale-alias regression with production-consistent collaborators

A stale alias makes the real AgentProfileService report
UNKNOWN_CAPABILITY, so the regression now binds unknown capabilities,
asserts the tool stays unregistered without surfacing an [unexpected]
error, and covers recovery once the alias resolves again. The rationale
moves into the mediaToolsRegistrar file header per the package comment
conventions.

---------

Co-authored-by: Mira <bj456736@users.noreply.github.com>
This commit is contained in:
bj456736 2026-08-17 21:46:52 +08:00 committed by GitHub
parent 5dffed2545
commit a7dc1ea284
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 58 additions and 7 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix an `[unexpected] Error2: Model "<alias>" 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.

View file

@ -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,

View file

@ -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<string>();
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 }));