feat(agent-core): ModelProvider interface and SingleModelProvider (#167)

This commit is contained in:
_Kerman 2026-05-28 22:27:09 +08:00 committed by GitHub
parent eb93fdfe4a
commit b5981a523b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 91 additions and 54 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Introduce `ModelProvider` interface and `SingleModelProvider` to decouple `Agent` from `ProviderManager`.

View file

@ -61,7 +61,7 @@ export class FullCompaction {
{
...DEFAULT_COMPACTION_CONFIG,
reservedContextSize:
agent.providerManager?.config.loopControl?.reservedContextSize ??
agent.kimiConfig?.loopControl?.reservedContextSize ??
DEFAULT_COMPACTION_CONFIG.reservedContextSize,
}
);

View file

@ -24,6 +24,7 @@ export class ConfigState {
constructor(protected readonly agent: Agent) {
this._cwd = agent.runtime.kaos.getcwd();
this._modelAlias = agent.modelProvider?.defaultModel;
}
update(changed: AgentConfigUpdateData): void {
@ -50,7 +51,7 @@ export class ConfigState {
if (changed.thinkingLevel !== undefined) {
this._thinkingLevel = resolveThinkingEffort(
changed.thinkingLevel,
this.agent.providerManager?.config.thinking,
this.agent.kimiConfig?.thinking,
);
}
if (changed.systemPrompt !== undefined) {
@ -128,7 +129,7 @@ export class ConfigState {
private get resolvedProviderConfig(): ResolvedRuntimeProvider | undefined {
if (this._modelAlias === undefined) return undefined;
return this.agent.providerManager?.resolveProviderConfig(this._modelAlias);
return this.agent.modelProvider?.resolveProviderConfig(this._modelAlias);
}
private tryResolvedProviderConfig(): ResolvedRuntimeProvider | undefined {

View file

@ -16,7 +16,7 @@ import type { EnabledPluginSessionStart } from '#/plugin';
import type { McpConnectionManager } from '../mcp';
import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../profile';
import type { ProviderManager } from '../session/provider-manager';
import type { ModelProvider } from '../session/provider-manager';
import type { RuntimeConfig } from '../runtime-types';
import type { SessionSubagentHost } from '../session/subagent-host';
import type { SkillRegistry } from '../skill';
@ -64,17 +64,16 @@ export interface AgentOptions {
readonly runtime: RuntimeConfig;
readonly config?: KimiConfig;
readonly homedir?: string;
readonly rpc?: SDKAgentRPC;
readonly rpc?: Partial<SDKAgentRPC>;
readonly persistence?: AgentRecordPersistence;
readonly type?: AgentType;
readonly generate?: typeof generate;
readonly compactionStrategy?: CompactionStrategy;
readonly providerManager?: ProviderManager | undefined;
readonly modelProvider?: ModelProvider | undefined;
readonly subagentHost?: SessionSubagentHost | undefined;
readonly skills?: SkillRegistry;
readonly mcp?: McpConnectionManager;
readonly hookEngine?: HookEngine;
readonly cronSessionDir?: string;
readonly permission?: PermissionManagerOptions | undefined;
readonly log?: Logger;
readonly telemetry?: TelemetryClient | undefined;
@ -82,20 +81,20 @@ export interface AgentOptions {
}
export class Agent {
readonly type: AgentType;
readonly runtime: RuntimeConfig;
readonly kimiConfig?: KimiConfig;
readonly homedir?: string;
readonly skills?: SkillManager;
readonly rpc?: Partial<SDKAgentRPC>;
readonly pluginSessionStarts: readonly EnabledPluginSessionStart[];
readonly rawGenerate: typeof generate;
readonly rpc?: SDKAgentRPC;
readonly modelProvider?: ModelProvider;
readonly subagentHost?: SessionSubagentHost;
readonly mcp?: McpConnectionManager;
readonly hooks?: HookEngine;
readonly log: Logger;
readonly telemetry: TelemetryClient;
readonly providerManager: ProviderManager | undefined;
readonly subagentHost: SessionSubagentHost | undefined;
readonly mcp: McpConnectionManager | undefined;
readonly hooks: HookEngine | undefined;
readonly type: AgentType;
readonly blobStore: BlobStore | undefined;
readonly records: AgentRecords;
readonly fullCompaction: FullCompaction;
@ -106,31 +105,29 @@ export class Agent {
readonly permission: PermissionManager;
readonly planMode: PlanMode;
readonly usage: UsageRecorder;
readonly skills: SkillManager | null;
readonly tools: ToolManager;
readonly background: BackgroundManager;
readonly cron: CronManager | null;
readonly replayBuilder: ReplayBuilder;
readonly log: Logger;
private lastLlmConfigLogSignature?: string;
constructor(options: AgentOptions) {
this.log = options.log ?? log;
this.kimiConfig = options.config;
this.type = options.type ?? 'main';
this.runtime = options.runtime;
this.kimiConfig = options.config;
this.homedir = options.homedir;
if (options.skills !== undefined) {
this.skills = new SkillManager(this, options.skills);
}
this.rpc = options.rpc;
this.pluginSessionStarts = options.pluginSessionStarts ?? [];
this.rawGenerate = options.generate ?? generate;
this.providerManager = options.providerManager;
this.modelProvider = options.modelProvider;
this.subagentHost = options.subagentHost;
this.mcp = options.mcp;
this.hooks = options.hookEngine;
this.type = options.type ?? 'main';
this.rpc = options.rpc;
this.log = options.log ?? log;
this.telemetry = options.telemetry ?? noopTelemetryClient;
this.blobStore = options.homedir
? new BlobStore({ blobsDir: join(options.homedir, 'blobs') })
: undefined;
@ -154,6 +151,7 @@ export class Agent {
this.permission = new PermissionManager(this, options.permission);
this.planMode = new PlanMode(this);
this.usage = new UsageRecorder(this);
this.skills = options.skills ? new SkillManager(this, options.skills) : null;
this.tools = new ToolManager(this);
this.background = new BackgroundManager(this);
this.cron = this.type === 'sub' ? null : new CronManager(this);
@ -170,7 +168,7 @@ export class Agent {
const withAuth =
modelAlias === undefined
? undefined
: this.providerManager?.createAuthResolverForModel(modelAlias, { log: this.log });
: this.modelProvider?.resolveAuth?.(modelAlias, { log: this.log });
if (withAuth === undefined) {
this.logLlmRequest(provider, systemPrompt, tools, history, options);
return this.rawGenerate(provider, systemPrompt, tools, history, callbacks, options);
@ -186,7 +184,7 @@ export class Agent {
get llm(): KosongLLM {
const model = this.config.model;
const provider = this.config.provider.withThinking(this.config.thinkingLevel);
const loopControl = this.providerManager?.config.loopControl;
const loopControl = this.kimiConfig?.loopControl;
const completionBudgetConfig = resolveCompletionBudget({
reservedContextSize: loopControl?.reservedContextSize,
});
@ -299,7 +297,7 @@ export class Agent {
// Validate the alias resolves before recording it so resume / runtime
// callers fail fast on missing aliases instead of deferring to the
// next prompt.
const resolved = this.providerManager?.resolveProviderConfig(payload.model);
const resolved = this.modelProvider?.resolveProviderConfig(payload.model);
if (this.config.modelAlias !== payload.model) {
this.config.update({ modelAlias: payload.model });
this.telemetry.track('model_switch', { model: payload.model });
@ -344,7 +342,7 @@ export class Agent {
this.context.clear();
},
activateSkill: (payload) => {
if (this.skills === undefined) {
if (this.skills === null) {
throw new KimiError(ErrorCodes.SKILL_NOT_FOUND, `Skill "${payload.name}" was not found`);
}
this.skills.activate(payload);
@ -363,7 +361,7 @@ export class Agent {
emitEvent(event: AgentEvent): void {
if (this.records.restoring) return;
void this.rpc?.emitEvent(event);
void this.rpc?.emitEvent?.(event);
}
emitStatusUpdated(): void {

View file

@ -131,11 +131,7 @@ export class PermissionManager {
const startedAt = Date.now();
let response: ApprovalResponse;
if (this.agent.rpc === undefined) {
response = {
decision: 'approved',
};
} else {
if (this.agent.rpc?.requestApproval) {
try {
response = await this.agent.rpc.requestApproval(
{
@ -163,6 +159,10 @@ export class PermissionManager {
? Promise.reject(error)
: this.permissionPolicyResolutionToPrepare(resolved, context, policyName);
}
} else {
response = {
decision: 'approved',
};
}
const sessionApprovalRule =

View file

@ -43,6 +43,9 @@ export class ToolManager {
constructor(protected readonly agent: Agent) {
this.attachMcpTools();
if (agent.config.hasProvider) {
this.initializeBuiltinTools();
}
}
protected get toolStore(): ToolStore {
@ -93,7 +96,7 @@ export class ToolManager {
return {
approvalRule: name,
execute: async (context) => {
return this.agent.rpc!.toolCall(
return this.agent.rpc!.toolCall!(
{
turnId: Number(context.turnId),
toolCallId: context.toolCallId,
@ -369,7 +372,7 @@ export class ToolManager {
new b.ReadMediaFileTool(kaos, workspace, modelCapabilities, videoUploader),
new b.EnterPlanModeTool(this.agent),
new b.ExitPlanModeTool(this.agent),
this.agent.rpc && new b.AskUserQuestionTool(this.agent),
this.agent.rpc?.requestQuestion && new b.AskUserQuestionTool(this.agent),
new b.TodoListTool(this.toolStore),
new b.TaskListTool(background),
new b.TaskOutputTool(background),
@ -377,8 +380,7 @@ export class ToolManager {
this.agent.cron && new b.CronCreateTool(this.agent.cron),
this.agent.cron && new b.CronListTool(this.agent.cron),
this.agent.cron && new b.CronDeleteTool(this.agent.cron),
this.agent.skills !== undefined &&
this.agent.skills.registry.listInvocableSkills().length > 0 &&
this.agent.skills?.registry.listInvocableSkills().length &&
new b.SkillTool(this.agent),
this.agent.subagentHost &&
new b.AgentTool(
@ -403,7 +405,7 @@ export class ToolManager {
if (uploadVideo === undefined) return undefined;
const modelAlias = this.agent.config.modelAlias!;
const withAuth = this.agent.providerManager?.createAuthResolverForModel(modelAlias, {
const withAuth = this.agent.modelProvider?.resolveAuth?.(modelAlias, {
log: this.agent.log,
});
if (withAuth === undefined) return (input) => uploadVideo(input);

View file

@ -362,7 +362,7 @@ export class TurnFlow {
while (true) {
signal.throwIfAborted();
const model = this.agent.config.model;
const loopControl = this.agent.providerManager?.config.loopControl;
const loopControl = this.agent.kimiConfig?.loopControl;
try {
const result = await runTurn({
turnId: String(turnId),

View file

@ -40,8 +40,10 @@ export type {
BackgroundTaskStatus,
} from './tools/background/manager';
export type { RuntimeConfig } from './runtime-types';
export { SingleModelProvider } from './session/provider-manager';
export type {
BearerTokenProvider,
ModelProvider,
OAuthTokenProviderResolver,
} from './session/provider-manager';

View file

@ -413,7 +413,7 @@ export class Session {
homedir,
skills: this.skills,
rpc: proxyWithExtraPayload(this.rpc, { agentId: id }),
providerManager: this.options.providerManager,
modelProvider: this.options.providerManager,
hookEngine: config.hookEngine ?? this.hookEngine,
subagentHost:
config.subagentHost ?? new SessionSubagentHost(this, id, this.backgroundTaskTimeoutMs()),

View file

@ -14,7 +14,6 @@ export type OAuthTokenProviderResolver = (
) => BearerTokenProvider | undefined;
export interface ResolvedRuntimeProvider {
readonly modelName: string;
readonly providerName: string;
readonly provider: KosongProviderConfig;
readonly modelCapabilities: ModelCapability;
@ -31,10 +30,41 @@ type AuthorizedRequest = <T>(
request: (auth: ProviderRequestAuth) => Promise<T>,
) => Promise<T>;
export class ProviderManager {
export interface ModelProvider {
readonly defaultModel?: string;
resolveProviderConfig(model: string): ResolvedRuntimeProvider;
resolveAuth?(model: string, options?: { readonly log?: Logger }): AuthorizedRequest | undefined;
}
export class SingleModelProvider implements ModelProvider {
constructor(
private readonly providerConfig: KosongProviderConfig,
private readonly modelCapabilities: ModelCapability = UNKNOWN_CAPABILITY,
) {}
get defaultModel(): string {
return this.providerConfig.model;
}
resolveProviderConfig(model: string): ResolvedRuntimeProvider {
if (model !== this.providerConfig.model) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`Model "${model}" is not supported by SingleModelProvider.`,
);
}
return {
modelCapabilities: this.modelCapabilities,
providerName: 'single-model-provider',
provider: this.providerConfig,
}
}
}
export class ProviderManager implements ModelProvider {
constructor(private readonly options: ProviderManagerOptions) {}
get config(): KimiConfig {
private get config(): KimiConfig {
const { config } = this.options;
return typeof config === 'function' ? config() : config;
}
@ -81,14 +111,13 @@ export class ProviderManager {
);
return {
modelName: model,
providerName,
provider,
modelCapabilities: resolveModelCapabilities(alias, provider),
};
}
createAuthResolverForModel(
resolveAuth(
model: string,
options?: { readonly log?: Logger },
): AuthorizedRequest | undefined {

View file

@ -104,7 +104,7 @@ export class AskUserQuestionTool implements BuiltinTool<AskUserQuestionInput> {
}: ExecutableToolContext,
): Promise<ExecutableToolResult> {
try {
const result = await this.agent.rpc!.requestQuestion(
const result = await this.agent.rpc!.requestQuestion!(
{
turnId: numericTurnId(turnId),
toolCallId,

View file

@ -105,7 +105,7 @@ export class SkillTool implements BuiltinTool<SkillToolInput> {
}
const skills = this.agent.skills;
if (skills === undefined) {
if (skills === null) {
return errorResult(`Skill "${args.skill}" not found in the current skill listing.`);
}
const skill = skills.registry.getSkill(args.skill);

View file

@ -1309,7 +1309,7 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
const providerManager = ctx.agent.providerManager;
const providerManager = ctx.agent.modelProvider;
if (providerManager === undefined) throw new Error('Expected provider manager');
const resolveProviderConfig = providerManager.resolveProviderConfig.bind(providerManager);
providerManager.resolveProviderConfig = (model) => ({

View file

@ -177,11 +177,12 @@ export class AgentTestContext {
);
this.agent = new Agent({
runtime,
config: this.kimiConfig,
rpc: this.createRpcProxy(),
persistence,
generate: options.generate ?? this.scriptedGenerate.generate,
compactionStrategy: options.compactionStrategy,
providerManager,
modelProvider: providerManager,
subagentHost: options.subagentHost,
type: options.type,
permission: options.permission,

View file

@ -6,7 +6,7 @@ describe('Agent question', () => {
it('roundtrips a question request through wire rpc', async () => {
const ctx = testAgent();
const resultPromise = ctx.agent.rpc!.requestQuestion(
const resultPromise = ctx.agent.rpc!.requestQuestion!(
{
questions: [
{
@ -29,7 +29,7 @@ describe('Agent question', () => {
it('sends multiple questions in one request', async () => {
const ctx = testAgent();
const resultPromise = ctx.agent.rpc!.requestQuestion(
const resultPromise = ctx.agent.rpc!.requestQuestion!(
{
questions: [
{

View file

@ -51,7 +51,7 @@ function makeAgent(
rpc,
skills,
persistence,
providerManager: testProviderManager(),
modelProvider: testProviderManager(),
});
agent.config.update({
cwd: process.cwd(),

View file

@ -67,7 +67,6 @@ describe('resolveRuntimeProvider model metadata', () => {
tool_use: true,
max_context_tokens: 1_000_000,
});
expect(resolved.modelName).toBe('kimi-code/kimi-for-coding');
expect(resolved.provider.model).toBe('kimi-for-coding');
});
@ -97,7 +96,6 @@ describe('resolveRuntimeProvider model metadata', () => {
});
expect(resolved.providerName).toBe('openai');
expect(resolved.modelName).toBe('gpt-alias');
expect(resolved.provider).toMatchObject({
type: 'openai',
model: 'gpt-runtime',