diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index b7da827428..07cca5bf65 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -69,6 +69,7 @@ import { observationArtifactAdapterSymbol, } from '@midscene/shared/agent-tools/observation-artifact'; import { + type CreateOpenAIClientFn, type IModelConfig, MIDSCENE_REPLANNING_CYCLE_LIMIT, ModelConfigManager, @@ -120,6 +121,35 @@ import { const debug = getDebug('agent'); const warn = getDebug('agent', { console: true }); +class AgentScopedModelConfigManager extends ModelConfigManager { + constructor( + private readonly baseManager: ModelConfigManager, + private readonly createOpenAIClient?: CreateOpenAIClientFn, + ) { + super(); + } + + override getModelConfig(intent: TIntent): IModelConfig { + return { + ...this.baseManager.getModelConfig(intent), + createOpenAIClient: this.createOpenAIClient, + }; + } + + override getUploadTestServerUrl(): string | undefined { + return this.baseManager.getUploadTestServerUrl(); + } + + override throwErrorIfNonVLModel() { + const modelConfig = this.getModelConfig('default'); + if (!modelConfig.modelFamily) { + throw new Error( + 'MIDSCENE_MODEL_FAMILY is not set to a multimodal model with UI localization, so element localization cannot be achieved. Check your model configuration. See https://midscenejs.com/model-strategy.html', + ); + } + } +} + export type AiActOptions = { cacheable?: boolean; fileChooserAccept?: string | string[]; @@ -328,9 +358,10 @@ export class Agent } private resolveModelRuntime(intent: TIntent): ModelRuntime { - const runtime = getModelRuntime( - this.modelConfigManager.getModelConfig(intent), - ); + const modelConfig: IModelConfig = { + ...this.modelConfigManager.getModelConfig(intent), + }; + const runtime = getModelRuntime(modelConfig); return { ...runtime, onUsage: (usage) => { @@ -422,12 +453,16 @@ export class Agent `opts.modelConfig must be a plain object map of env keys to values, but got ${typeof opts?.modelConfig}`, ); } - // Create ModelConfigManager if modelConfig or createOpenAIClient is provided - // Otherwise, use the global config manager - const hasCustomConfig = opts?.modelConfig || opts?.createOpenAIClient; - this.modelConfigManager = hasCustomConfig - ? new ModelConfigManager(opts?.modelConfig, opts?.createOpenAIClient) - : globalModelConfigManager; + // Explicit modelConfig is isolated from global configuration. + // A custom client factory alone still uses the global model values. + this.modelConfigManager = opts?.modelConfig + ? new ModelConfigManager(opts.modelConfig, opts.createOpenAIClient) + : opts?.createOpenAIClient + ? new AgentScopedModelConfigManager( + globalModelConfigManager, + opts.createOpenAIClient, + ) + : globalModelConfigManager; this.onTaskStartTip = this.opts.onTaskStartTip; diff --git a/packages/core/tests/unit-test/agent-custom-model.test.ts b/packages/core/tests/unit-test/agent-custom-model.test.ts index 9186c23d42..6635b39900 100644 --- a/packages/core/tests/unit-test/agent-custom-model.test.ts +++ b/packages/core/tests/unit-test/agent-custom-model.test.ts @@ -11,6 +11,7 @@ import { MIDSCENE_PLANNING_MODEL_API_KEY, MIDSCENE_PLANNING_MODEL_BASE_URL, MIDSCENE_PLANNING_MODEL_NAME, + globalModelConfigManager, } from '@midscene/shared/env'; import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core'; @@ -37,12 +38,22 @@ const createMockInterface = () => actionSpace: () => [], }) as any; +const stubModelEnv = (config: Record) => { + for (const [key, value] of Object.entries(config)) { + rs.stubEnv(key, value); + } + globalModelConfigManager.clearModelConfigMap(); +}; + describe('Agent with custom OpenAI client', () => { beforeEach(() => { rs.mock('openai'); + stubModelEnv(defaultModelConfig); }); afterEach(() => { + rs.unstubAllEnvs(); + globalModelConfigManager.clearModelConfigMap(); rs.clearAllMocks(); }); @@ -245,6 +256,84 @@ describe('Agent with custom OpenAI client', () => { }); describe('constructor with createOpenAIClient', () => { + it('should expose createOpenAIClient on public modelConfigManager for factory-only agents', () => { + const mockCreateClient: CreateOpenAIClientFn = rs.fn(async () => ({ + chat: { completions: { create: rs.fn() } }, + })); + const agent = new Agent(createMockInterface(), { + createOpenAIClient: mockCreateClient, + }); + + const defaultConfig = agent.modelConfigManager.getModelConfig('default'); + const insightConfig = agent.modelConfigManager.getModelConfig('insight'); + + expect(defaultConfig.modelName).toBe( + defaultModelConfig[MIDSCENE_MODEL_NAME], + ); + expect(defaultConfig.createOpenAIClient).toBe(mockCreateClient); + expect(insightConfig.modelName).toBe( + defaultModelConfig[MIDSCENE_MODEL_NAME], + ); + expect(insightConfig.createOpenAIClient).toBe(mockCreateClient); + }); + + it('should combine global model config with an agent-scoped createOpenAIClient', () => { + const mockCreateClient: CreateOpenAIClientFn = rs.fn(async () => ({ + chat: { completions: { create: rs.fn() } }, + })); + const agent = new Agent(createMockInterface(), { + createOpenAIClient: mockCreateClient, + }); + + const runtime = (agent as any).resolveModelRuntime('default'); + + expect(runtime.config.modelName).toBe( + defaultModelConfig[MIDSCENE_MODEL_NAME], + ); + expect(runtime.config.openaiApiKey).toBe( + defaultModelConfig[MIDSCENE_MODEL_API_KEY], + ); + expect(runtime.config.openaiBaseURL).toBe( + defaultModelConfig[MIDSCENE_MODEL_BASE_URL], + ); + expect(runtime.config.createOpenAIClient).toBe(mockCreateClient); + expect(mockCreateClient).not.toHaveBeenCalled(); + }); + + it('should isolate createOpenAIClient between agents sharing global config', () => { + const firstCreateClient: CreateOpenAIClientFn = rs.fn(async () => ({ + chat: { completions: { create: rs.fn() } }, + })); + const secondCreateClient: CreateOpenAIClientFn = rs.fn(async () => ({ + chat: { completions: { create: rs.fn() } }, + })); + const firstAgent = new Agent(createMockInterface(), { + createOpenAIClient: firstCreateClient, + }); + const secondAgent = new Agent(createMockInterface(), { + createOpenAIClient: secondCreateClient, + }); + + const firstRuntime = (firstAgent as any).resolveModelRuntime('default'); + const secondRuntime = (secondAgent as any).resolveModelRuntime('default'); + const firstRuntimeAgain = (firstAgent as any).resolveModelRuntime( + 'default', + ); + + expect(firstRuntime.config.modelName).toBe( + defaultModelConfig[MIDSCENE_MODEL_NAME], + ); + expect(secondRuntime.config.modelName).toBe( + defaultModelConfig[MIDSCENE_MODEL_NAME], + ); + expect(firstRuntime.config).not.toBe(secondRuntime.config); + expect(firstRuntime.config.createOpenAIClient).toBe(firstCreateClient); + expect(secondRuntime.config.createOpenAIClient).toBe(secondCreateClient); + expect(firstRuntimeAgain.config.createOpenAIClient).toBe( + firstCreateClient, + ); + }); + it('should accept createOpenAIClient in AgentOpt with modelConfig', () => { const mockCreateClient = rs.fn(async () => ({ chat: { completions: { create: rs.fn() } },