Skip to content
Draft
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
53 changes: 44 additions & 9 deletions packages/core/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
observationArtifactAdapterSymbol,
} from '@midscene/shared/agent-tools/observation-artifact';
import {
type CreateOpenAIClientFn,
type IModelConfig,
MIDSCENE_REPLANNING_CYCLE_LIMIT,
ModelConfigManager,
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -328,9 +358,10 @@ export class Agent<InterfaceType extends AbstractInterface = AbstractInterface>
}

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) => {
Expand Down Expand Up @@ -422,12 +453,16 @@ export class Agent<InterfaceType extends AbstractInterface = AbstractInterface>
`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;

Expand Down
89 changes: 89 additions & 0 deletions packages/core/tests/unit-test/agent-custom-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -37,12 +38,22 @@ const createMockInterface = () =>
actionSpace: () => [],
}) as any;

const stubModelEnv = (config: Record<string, string>) => {
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();
});

Expand Down Expand Up @@ -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() } },
Expand Down
Loading