@chen-rmag/core-infra 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/ProjectContextManager.d.ts +30 -0
- package/dist/ProjectContextManager.js +41 -0
- package/dist/directory-validator.d.ts +28 -0
- package/dist/directory-validator.js +90 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +44 -0
- package/dist/mcp/file-mcp-manager.d.ts +13 -0
- package/dist/mcp/file-mcp-manager.js +45 -0
- package/dist/mcp/index.d.ts +20 -0
- package/dist/mcp/index.js +16 -0
- package/dist/mcp/mcp-client.d.ts +127 -0
- package/dist/mcp/mcp-client.js +165 -0
- package/dist/mcp/mcp-manager.d.ts +20 -0
- package/dist/mcp/mcp-manager.js +98 -0
- package/dist/mcp/playwright-mcp-manager.d.ts +18 -0
- package/dist/mcp/playwright-mcp-manager.js +115 -0
- package/dist/model.d.ts +10 -0
- package/dist/model.js +207 -0
- package/dist/repositories/BaseRepository.d.ts +68 -0
- package/dist/repositories/BaseRepository.js +212 -0
- package/dist/repositories/DirectoryRepository.d.ts +69 -0
- package/dist/repositories/DirectoryRepository.js +335 -0
- package/dist/repositories/ExplorationRepository.d.ts +33 -0
- package/dist/repositories/ExplorationRepository.js +53 -0
- package/dist/repositories/FileRepository.d.ts +55 -0
- package/dist/repositories/FileRepository.js +131 -0
- package/dist/repositories/ModelConfigRepository.d.ts +33 -0
- package/dist/repositories/ModelConfigRepository.js +51 -0
- package/dist/repositories/ProjectRepository.d.ts +31 -0
- package/dist/repositories/ProjectRepository.js +66 -0
- package/dist/repositories/SettingsRepository.d.ts +18 -0
- package/dist/repositories/SettingsRepository.js +71 -0
- package/dist/repositories/TableDataRepository.d.ts +21 -0
- package/dist/repositories/TableDataRepository.js +32 -0
- package/dist/repositories/TestCaseRepository.d.ts +120 -0
- package/dist/repositories/TestCaseRepository.js +463 -0
- package/dist/repositories/TestPlanRepository.d.ts +34 -0
- package/dist/repositories/TestPlanRepository.js +79 -0
- package/dist/repositories/TestResultRepository.d.ts +29 -0
- package/dist/repositories/TestResultRepository.js +53 -0
- package/dist/repositories/index.d.ts +16 -0
- package/dist/repositories/index.js +30 -0
- package/dist/storageService.d.ts +129 -0
- package/dist/storageService.js +297 -0
- package/dist/types.d.ts +217 -0
- package/dist/types.js +2 -0
- package/package.json +32 -0
- package/src/directory-validator.ts +98 -0
- package/src/index.ts +26 -0
- package/src/mcp/file-mcp-manager.ts +50 -0
- package/src/mcp/index.ts +35 -0
- package/src/mcp/mcp-client.ts +209 -0
- package/src/mcp/mcp-manager.ts +118 -0
- package/src/mcp/playwright-mcp-manager.ts +127 -0
- package/src/model.ts +234 -0
- package/src/repositories/BaseRepository.ts +193 -0
- package/src/repositories/DirectoryRepository.ts +393 -0
- package/src/repositories/ExplorationRepository.ts +57 -0
- package/src/repositories/FileRepository.ts +153 -0
- package/src/repositories/ModelConfigRepository.ts +55 -0
- package/src/repositories/ProjectRepository.ts +70 -0
- package/src/repositories/SettingsRepository.ts +38 -0
- package/src/repositories/TableDataRepository.ts +33 -0
- package/src/repositories/TestCaseRepository.ts +521 -0
- package/src/repositories/TestPlanRepository.ts +89 -0
- package/src/repositories/TestResultRepository.ts +56 -0
- package/src/repositories/index.ts +17 -0
- package/src/storageService.ts +404 -0
- package/src/types.ts +246 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { MCPClient, MCPTool } from '@ai-test/core-infra/src/mcp/mcp-client';
|
|
2
|
+
import { FileMCPManager } from '@ai-test/core-infra/src/mcp/file-mcp-manager';
|
|
3
|
+
import { PlaywrightMCPManager } from '@ai-test/core-infra/src/mcp/playwright-mcp-manager';
|
|
4
|
+
import { GroupedMCPManager } from '@ai-test/core-infra/src/mcp';
|
|
5
|
+
|
|
6
|
+
export interface ToolGroupInfo {
|
|
7
|
+
groupId: string;
|
|
8
|
+
description: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class MCPManager {
|
|
12
|
+
private managers: GroupedMCPManager[] = [];
|
|
13
|
+
private connected: boolean = false;
|
|
14
|
+
private language: string = 'zh-CN';
|
|
15
|
+
|
|
16
|
+
getManagers(): GroupedMCPManager[] {
|
|
17
|
+
return this.managers;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
getClient(groupName: string): MCPClient {
|
|
21
|
+
for (const manager of this.managers) {
|
|
22
|
+
if (manager.getToolGroupInfo()[groupName]) {
|
|
23
|
+
return manager.getClient();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
throw new Error(`MCP Client for group ${groupName} is not found`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async start(language: string, forced: boolean = false): Promise<void> {
|
|
31
|
+
const playwrightClient = new PlaywrightMCPManager({ language: language });
|
|
32
|
+
const fileClient = new FileMCPManager();
|
|
33
|
+
this.managers = [playwrightClient, fileClient];
|
|
34
|
+
if (language !== this.language) {
|
|
35
|
+
forced = true;
|
|
36
|
+
}
|
|
37
|
+
this.language = language;
|
|
38
|
+
|
|
39
|
+
await Promise.all(this.managers.map(manager => manager.start(forced)));
|
|
40
|
+
this.connected = true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async stop() {
|
|
44
|
+
for (const manager of this.managers) {
|
|
45
|
+
try {
|
|
46
|
+
await manager.getClient().disconnect();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error(`Error disconnecting MCP Client:`, error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
this.connected = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
isConnected(): boolean {
|
|
55
|
+
return this.connected;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getToolGroupInfo(): ToolGroupInfo[] {
|
|
59
|
+
return this.managers.flatMap((manager) =>
|
|
60
|
+
manager.groups
|
|
61
|
+
.filter((group) => !group.loadByDefault)
|
|
62
|
+
.map((group) => ({
|
|
63
|
+
groupId: group.name,
|
|
64
|
+
description: group.description,
|
|
65
|
+
}))
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getAllAvailableTools(): MCPTool[] {
|
|
70
|
+
let allTools: MCPTool[] = [];
|
|
71
|
+
for (const manager of this.managers) {
|
|
72
|
+
const tools = manager.getClient().getAvailableTools().filter(tool => {
|
|
73
|
+
return manager.groups.some(group => !group.tools || group.tools.includes(tool.name));
|
|
74
|
+
});
|
|
75
|
+
allTools = allTools.concat(tools);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return allTools;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getAvailableTools(groupName: string): MCPTool[] {
|
|
82
|
+
for (const manager of this.managers) {
|
|
83
|
+
const group = manager.groups.find(g => g.name === groupName);
|
|
84
|
+
if (group) {
|
|
85
|
+
const toolNames = group.tools;
|
|
86
|
+
const all = manager.getClient().getAvailableTools();
|
|
87
|
+
if (!toolNames) {
|
|
88
|
+
return all;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return toolNames.filter(name => all.some(tool => tool.name === name)).map(name => all.find(tool => tool.name === name)!);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw new Error(`Tool group ${groupName} not found`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
getDefaultTools(): MCPTool[] {
|
|
100
|
+
const allTools: MCPTool[] = [];
|
|
101
|
+
for (const manager of this.managers) {
|
|
102
|
+
for (const group of manager.groups) {
|
|
103
|
+
if (!group.loadByDefault || !group.tools) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const tools = manager.getClient().getAvailableTools();
|
|
108
|
+
for (const tool of tools) {
|
|
109
|
+
if (group.tools.includes(tool.name)) {
|
|
110
|
+
allTools.push(tool);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return allTools;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP (Model Context Protocol) Client for Playwright integration
|
|
3
|
+
* Integrates with https://github.com/microsoft/playwright-mcp
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { MCPClient, MCPConfig } from './mcp-client';
|
|
9
|
+
import { AbstractGroupedMCPManager } from '.';
|
|
10
|
+
|
|
11
|
+
export interface PlaywrightMCPOpts {
|
|
12
|
+
language: string,
|
|
13
|
+
headless?: boolean,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class PlaywrightMCPManager extends AbstractGroupedMCPManager {
|
|
17
|
+
private playwrightConfigPath?: string;
|
|
18
|
+
private playwrightConfigDir?: string;
|
|
19
|
+
private opts: PlaywrightMCPOpts;
|
|
20
|
+
private client?: MCPClient;
|
|
21
|
+
|
|
22
|
+
constructor(opts: PlaywrightMCPOpts) {
|
|
23
|
+
super([{
|
|
24
|
+
name: 'playwright_basic',
|
|
25
|
+
description: 'Basic Playwright tools for browser interactions',
|
|
26
|
+
tools: ['browser_navigate', 'browser_click', 'browser_fill_form', 'browser_press_key', 'browser_run_code', 'browser_select_option', 'browser_snapshot', 'browser_type', 'browser_wait_for'],
|
|
27
|
+
loadByDefault: true,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'playwright_browser_manage',
|
|
31
|
+
description: `Playwright tools for browser and tab management, including: browser window resize, go back to previous page, close a page, list/create/close/select a tab`,
|
|
32
|
+
tools: ['browser_resize', 'browser_navigate_back', 'browser_close', 'browser_tabs'],
|
|
33
|
+
loadByDefault: false,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'playwright_other',
|
|
37
|
+
description: 'Other Playwright tools for browser interactions, including: mouse hover on element, drag and drop an element, upload file, handle dialog, evaluate JavaScript on page or element',
|
|
38
|
+
tools: ['browser_hover', 'browser_drag', 'browser_file_upload', 'browser_handle_dialog', 'browser_evaluate'],
|
|
39
|
+
loadByDefault: false,
|
|
40
|
+
}
|
|
41
|
+
]);
|
|
42
|
+
this.opts = opts;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async start(forced: boolean = false) {
|
|
46
|
+
this.prepareTempPlaywrightConfig(this.opts.language);
|
|
47
|
+
const client = this.createClient(this.opts);
|
|
48
|
+
this.client = client;
|
|
49
|
+
try {
|
|
50
|
+
await client.connect(forced);
|
|
51
|
+
} finally {
|
|
52
|
+
this.cleanupTempPlaywrightConfig();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getClient(): MCPClient {
|
|
57
|
+
if (!this.client) {
|
|
58
|
+
throw new Error("MCP Client is not started yet");
|
|
59
|
+
}
|
|
60
|
+
return this.client;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private createClient(opts: PlaywrightMCPOpts): MCPClient {
|
|
64
|
+
// Build args with headless mode
|
|
65
|
+
const args = [
|
|
66
|
+
'@cotestdev/mcp_playwright@latest',
|
|
67
|
+
'--image-responses', 'omit',
|
|
68
|
+
'--caps',
|
|
69
|
+
'core,core-tabs,extra'
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
if (this.playwrightConfigPath) {
|
|
73
|
+
args.push('--config', this.playwrightConfigPath);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Explicitly set headless or headed mode
|
|
77
|
+
// Playwright MCP requires explicit declaration of the mode
|
|
78
|
+
if (opts.headless) {
|
|
79
|
+
args.push('--headless');
|
|
80
|
+
console.log('Browser will run in HEADLESS mode (background)');
|
|
81
|
+
} else {
|
|
82
|
+
console.log('Browser will run in HEADED mode (visible window)');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const mcpConfig: MCPConfig = {
|
|
86
|
+
cmd: 'npx',
|
|
87
|
+
args: args,
|
|
88
|
+
env: { ...process.env } as Record<string, string>,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return new MCPClient(mcpConfig);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private prepareTempPlaywrightConfig(language: string) {
|
|
95
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'playwright-mcp-'));
|
|
96
|
+
const configPath = path.join(tempDir, 'mcp.config.json');
|
|
97
|
+
|
|
98
|
+
const configContents = `
|
|
99
|
+
{
|
|
100
|
+
"browser": {
|
|
101
|
+
"contextOptions": {
|
|
102
|
+
"locale": "${language}"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
fs.writeFileSync(configPath, configContents, 'utf8');
|
|
109
|
+
|
|
110
|
+
this.playwrightConfigDir = tempDir;
|
|
111
|
+
this.playwrightConfigPath = configPath;
|
|
112
|
+
console.log('Temporary Playwright config created at', configPath);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private cleanupTempPlaywrightConfig(): void {
|
|
116
|
+
if (!this.playwrightConfigDir) return;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
fs.rmSync(this.playwrightConfigDir, { recursive: true, force: true });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error('Failed to remove temporary Playwright config directory:', error);
|
|
122
|
+
} finally {
|
|
123
|
+
this.playwrightConfigDir = undefined;
|
|
124
|
+
this.playwrightConfigPath = undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { ModelConfig } from "./types";
|
|
2
|
+
import { BaseLanguageModel } from "@langchain/core/language_models/base";
|
|
3
|
+
import { ChatOpenAI } from '@langchain/openai';
|
|
4
|
+
import { ChatAnthropic } from '@langchain/anthropic';
|
|
5
|
+
import { ChatMistralAI } from '@langchain/mistralai';
|
|
6
|
+
import { DynamicStructuredTool } from '@langchain/core/tools';
|
|
7
|
+
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
|
8
|
+
import z from "zod";
|
|
9
|
+
import { MCPManager } from "./mcp/mcp-manager";
|
|
10
|
+
|
|
11
|
+
export function createModel(
|
|
12
|
+
config: ModelConfig,
|
|
13
|
+
bindTools: boolean = false,
|
|
14
|
+
mcpManager?: MCPManager,
|
|
15
|
+
useDefaultFuncs: boolean = false,
|
|
16
|
+
): BaseLanguageModel {
|
|
17
|
+
let model: BaseLanguageModel;
|
|
18
|
+
|
|
19
|
+
if (config.provider === 'openai') {
|
|
20
|
+
const openaiConfig = {
|
|
21
|
+
apiKey: config.apiKey,
|
|
22
|
+
model: config.modelName || 'gpt-4-turbo',
|
|
23
|
+
...config,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (config.baseURL) {
|
|
27
|
+
(openaiConfig as Record<string, unknown>).configuration = {
|
|
28
|
+
baseURL: config.baseURL,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
model = new ChatOpenAI(openaiConfig as Record<string, unknown>);
|
|
33
|
+
} else if (config.provider === 'anthropic') {
|
|
34
|
+
const anthropicConfig = {
|
|
35
|
+
apiKey: config.apiKey,
|
|
36
|
+
model: config.modelName || 'claude-3-sonnet-20240229',
|
|
37
|
+
anthropicApiUrl: config.baseURL,
|
|
38
|
+
clientOptions: {
|
|
39
|
+
baseURL: config.baseURL,
|
|
40
|
+
apiKey: config.apiKey,
|
|
41
|
+
},
|
|
42
|
+
...config,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
model = new ChatAnthropic(anthropicConfig as Record<string, unknown>);
|
|
46
|
+
} else if (config.provider === 'mistral') {
|
|
47
|
+
// Mistral uses ChatMistralAI package
|
|
48
|
+
const mistralConfig = {
|
|
49
|
+
apiKey: config.apiKey,
|
|
50
|
+
model: config.modelName || 'mistral-large-latest',
|
|
51
|
+
...config,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
model = new ChatMistralAI(mistralConfig as Record<string, unknown>);
|
|
55
|
+
} else if (config.provider === 'openrouter') {
|
|
56
|
+
// OpenRouter uses OpenAI-compatible API
|
|
57
|
+
const openrouterBaseURL = config.baseURL;
|
|
58
|
+
const openrouterConfig = {
|
|
59
|
+
apiKey: config.apiKey,
|
|
60
|
+
model: config.modelName,
|
|
61
|
+
configuration: { baseURL: openrouterBaseURL },
|
|
62
|
+
...config,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
model = new ChatOpenAI(openrouterConfig as Record<string, unknown>);
|
|
66
|
+
} else if (config.provider === 'ollama') {
|
|
67
|
+
const baseURL = config.baseURL || 'http://localhost:11434';
|
|
68
|
+
model = new ChatOpenAI({
|
|
69
|
+
apiKey: 'ollama',
|
|
70
|
+
model: config.modelName || 'llama2',
|
|
71
|
+
configuration: { baseURL },
|
|
72
|
+
...config,
|
|
73
|
+
} as Record<string, unknown>);
|
|
74
|
+
} else {
|
|
75
|
+
throw new Error(`Unknown model provider: ${config.provider}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (bindTools && mcpManager) {
|
|
79
|
+
model = bindPlaywrightTools(model, mcpManager, useDefaultFuncs);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return model;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Bind Playwright MCP tools to the model
|
|
87
|
+
* Creates LangChain tools from MCP tool definitions
|
|
88
|
+
*/
|
|
89
|
+
function bindPlaywrightTools(model: BaseLanguageModel, mcpManager: MCPManager, useDefaultFuncs: boolean): BaseLanguageModel {
|
|
90
|
+
const defaultTools = mcpManager.getDefaultTools();
|
|
91
|
+
// Convert MCP tools to LangChain tools (ensure each tool has its own JSON-schema-as-Zod schema)
|
|
92
|
+
const langchainTools: DynamicStructuredTool[] = defaultTools.map(mcpTool => {
|
|
93
|
+
const schema = convertToZodSchema(mcpTool.inputSchema || {});
|
|
94
|
+
return new DynamicStructuredTool({
|
|
95
|
+
name: mcpTool.name,
|
|
96
|
+
description: mcpTool.description,
|
|
97
|
+
schema,
|
|
98
|
+
func: async (input, runner, config) => {
|
|
99
|
+
const abortSignal = config?.signal ?? new AbortController().signal;
|
|
100
|
+
for (const manager of mcpManager.getManagers()) {
|
|
101
|
+
const tool = manager.getClient().getToolByName(mcpTool.name);
|
|
102
|
+
if (tool) {
|
|
103
|
+
return manager.getClient().doCallTool(mcpTool.name, input as Record<string, unknown>, abortSignal);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (useDefaultFuncs) {
|
|
111
|
+
const finishTool = {
|
|
112
|
+
name: "finish_test",
|
|
113
|
+
description: "Call this tool ONLY when the test goal has been fully achieved or failed to achieve. After calling this, no more tools should be called.",
|
|
114
|
+
schema: z.object({
|
|
115
|
+
final_summary: z.optional(z.string()).describe("A brief summary confirming the test goal was achieved."),
|
|
116
|
+
}),
|
|
117
|
+
func: async () => { },
|
|
118
|
+
}
|
|
119
|
+
const getToolGroupsTool = {
|
|
120
|
+
name: "get_tools",
|
|
121
|
+
description: "Get available tools in a group.",
|
|
122
|
+
schema: z.object({
|
|
123
|
+
group_name: z.string().describe("The name of the tool group."),
|
|
124
|
+
}),
|
|
125
|
+
func: async () => {
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
const callToolTool = {
|
|
129
|
+
name: "call_tool",
|
|
130
|
+
description: "Call a tool in a group by name.",
|
|
131
|
+
schema: z.object({
|
|
132
|
+
group_name: z.string().describe("Tool group name."),
|
|
133
|
+
tool_name: z.string().describe("Tool name."),
|
|
134
|
+
args: z.object().describe("Tool parameters."),
|
|
135
|
+
}),
|
|
136
|
+
func: async () => {
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
const reuseTestTool = {
|
|
140
|
+
name: "reuse_test",
|
|
141
|
+
description: "Reuse a test from the provided reusable tests.",
|
|
142
|
+
schema: z.object({
|
|
143
|
+
id: z.string().describe("ID of the reusable test."),
|
|
144
|
+
args: z.object().describe("Reusable test"),
|
|
145
|
+
}),
|
|
146
|
+
func: async () => {
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
langchainTools.push(new DynamicStructuredTool(finishTool), new DynamicStructuredTool(callToolTool), new DynamicStructuredTool(getToolGroupsTool), new DynamicStructuredTool(reuseTestTool));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// For models that support tool binding (OpenAI, Anthropic), bind the tools
|
|
153
|
+
// Note: Not all models support bindTools(), so we skip it if not available
|
|
154
|
+
const modelAny = model as BaseChatModel;
|
|
155
|
+
if (modelAny && typeof modelAny.bindTools === 'function') {
|
|
156
|
+
// Force the model to use tools with tool_choice: 'required'
|
|
157
|
+
// This ensures the model will always invoke a tool rather than responding directly
|
|
158
|
+
// Only OpenAI and Anthropic fully support this parameter
|
|
159
|
+
|
|
160
|
+
return modelAny.bindTools(langchainTools, {
|
|
161
|
+
tool_choice: 'required',
|
|
162
|
+
}) as BaseLanguageModel;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return model;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Convert a plain object schema to a Zod schema
|
|
171
|
+
* Handles conversion of MCP tool input schemas to LangChain-compatible Zod types
|
|
172
|
+
*/
|
|
173
|
+
export function convertToZodSchema(
|
|
174
|
+
schema: Record<string, unknown>
|
|
175
|
+
): z.ZodType {
|
|
176
|
+
// If schema is already a Zod type, return it
|
|
177
|
+
if (schema instanceof z.ZodType) {
|
|
178
|
+
return schema;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Handle JSON Schema format (common from MCP tools)
|
|
182
|
+
if (schema.type === 'object' && schema.properties) {
|
|
183
|
+
const properties: Record<string, z.ZodTypeAny> = {};
|
|
184
|
+
|
|
185
|
+
const props = schema.properties as Record<string, unknown>;
|
|
186
|
+
const required = (schema.required as string[]) || [];
|
|
187
|
+
|
|
188
|
+
for (const [key, value] of Object.entries(props)) {
|
|
189
|
+
const propSchema = value as Record<string, unknown>;
|
|
190
|
+
const zodType = convertPropertyToZod(propSchema);
|
|
191
|
+
|
|
192
|
+
if (required.includes(key)) {
|
|
193
|
+
properties[key] = zodType;
|
|
194
|
+
} else {
|
|
195
|
+
properties[key] = zodType.optional();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return z.object(properties);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Convert a single property schema to Zod type
|
|
204
|
+
*/
|
|
205
|
+
function convertPropertyToZod(schema: Record<string, unknown>): z.ZodTypeAny {
|
|
206
|
+
const type = schema.type as string;
|
|
207
|
+
|
|
208
|
+
switch (type) {
|
|
209
|
+
case 'string':
|
|
210
|
+
return z.string().describe((schema.description as string) || '');
|
|
211
|
+
|
|
212
|
+
case 'number':
|
|
213
|
+
case 'integer':
|
|
214
|
+
return z.number().describe((schema.description as string) || '');
|
|
215
|
+
|
|
216
|
+
case 'boolean':
|
|
217
|
+
return z.boolean().describe((schema.description as string) || '');
|
|
218
|
+
|
|
219
|
+
case 'array':
|
|
220
|
+
const items = schema.items as Record<string, unknown>;
|
|
221
|
+
const itemType = items ? convertPropertyToZod(items) : z.any();
|
|
222
|
+
return z.array(itemType).describe((schema.description as string) || '');
|
|
223
|
+
|
|
224
|
+
case 'object':
|
|
225
|
+
return z.object({}).catchall(z.any()).describe((schema.description as string) || '');
|
|
226
|
+
|
|
227
|
+
default:
|
|
228
|
+
return z.any();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Fallback: accept any object with catchall
|
|
233
|
+
return z.object({}).catchall(z.any());
|
|
234
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { writeFile, readFile, readdir, rm } from 'fs/promises';
|
|
4
|
+
import path, { join } from 'path';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'fs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Base repository class providing common file I/O operations
|
|
9
|
+
* Implements the base functionality for all data repositories
|
|
10
|
+
*
|
|
11
|
+
* Supports project-isolated data storage:
|
|
12
|
+
* - When projectId is active: ~/.test_agent/projects/{projectId}/{subDir}/
|
|
13
|
+
* - When no project: ~/.test_agent/{subDir}/
|
|
14
|
+
*/
|
|
15
|
+
export abstract class BaseRepository {
|
|
16
|
+
protected storageDir: string;
|
|
17
|
+
protected subDir: string;
|
|
18
|
+
protected explicitProjectId?: string;
|
|
19
|
+
|
|
20
|
+
constructor(subDir: string) {
|
|
21
|
+
this.storageDir = join(homedir(), '.test_agent');
|
|
22
|
+
this.subDir = subDir;
|
|
23
|
+
this.ensureDir();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Ensure the subdirectory exists
|
|
28
|
+
*/
|
|
29
|
+
protected ensureDir(): void {
|
|
30
|
+
const dirPath = this.getBaseDir();
|
|
31
|
+
if (!existsSync(dirPath)) {
|
|
32
|
+
mkdirSync(dirPath, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the base directory for this repository
|
|
38
|
+
* When a project is active:
|
|
39
|
+
* - For 'projects': returns ~/.test_agent/projects (projects.json location)
|
|
40
|
+
* - For other repos: returns ~/.test_agent/projects/{projectId}/{subDir}
|
|
41
|
+
* When no project:
|
|
42
|
+
* - returns ~/.test_agent/{subDir}
|
|
43
|
+
*/
|
|
44
|
+
protected getBaseDir(): string {
|
|
45
|
+
// Global directory: ~/.test_agent/{subDir}
|
|
46
|
+
return join(this.storageDir, this.subDir);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get full file path for a specific ID
|
|
51
|
+
*/
|
|
52
|
+
protected getFilePath(id: string, extension: string = 'json'): string {
|
|
53
|
+
return join(this.getBaseDir(), `${id}.${extension}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Save data to file (synchronous)
|
|
58
|
+
*/
|
|
59
|
+
protected saveSync(id: string, data: unknown, extension: string = 'json'): void {
|
|
60
|
+
const filePath = this.getFilePath(id, extension);
|
|
61
|
+
try {
|
|
62
|
+
this.createDirectoriesRecursively(filePath);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
}
|
|
65
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private createDirectoriesRecursively(targetPath: string): void {
|
|
69
|
+
// If target path is a file path, get its parent directory; otherwise, use target path as directory
|
|
70
|
+
const dir = path.extname(targetPath) ? path.dirname(targetPath) : targetPath;
|
|
71
|
+
|
|
72
|
+
if (!existsSync(dir)) {
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Save data to file (asynchronous)
|
|
79
|
+
*/
|
|
80
|
+
protected async save(id: string, data: unknown, extension: string = 'json'): Promise<void> {
|
|
81
|
+
const filePath = this.getFilePath(id, extension);
|
|
82
|
+
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Load data from file (synchronous)
|
|
87
|
+
*/
|
|
88
|
+
protected loadSync<T>(id: string, extension: string = 'json'): T | null {
|
|
89
|
+
const filePath = this.getFilePath(id, extension);
|
|
90
|
+
if (!existsSync(filePath)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
94
|
+
return JSON.parse(content) as T;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Load data from file (asynchronous)
|
|
99
|
+
*/
|
|
100
|
+
protected async load<T>(id: string, extension: string = 'json'): Promise<T | null> {
|
|
101
|
+
const filePath = this.getFilePath(id, extension);
|
|
102
|
+
try {
|
|
103
|
+
const content = await readFile(filePath, 'utf-8');
|
|
104
|
+
return JSON.parse(content) as T;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* List all files in the directory
|
|
112
|
+
*/
|
|
113
|
+
protected async listAll<T>(filter?: (filename: string) => boolean): Promise<T[]> {
|
|
114
|
+
const dirPath = this.getBaseDir();
|
|
115
|
+
try {
|
|
116
|
+
const files = await readdir(dirPath);
|
|
117
|
+
const results: T[] = [];
|
|
118
|
+
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
if (filter && !filter(file)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!file.endsWith('.json')) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const filePath = join(dirPath, file);
|
|
127
|
+
const content = await readFile(filePath, 'utf-8');
|
|
128
|
+
results.push(JSON.parse(content) as T);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return results;
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* List all files synchronously
|
|
139
|
+
*/
|
|
140
|
+
protected listAllSync<T>(filter?: (filename: string) => boolean): T[] {
|
|
141
|
+
const dirPath = this.getBaseDir();
|
|
142
|
+
try {
|
|
143
|
+
const files = readdirSync(dirPath);
|
|
144
|
+
const results: T[] = [];
|
|
145
|
+
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
if (filter && !filter(file)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!file.endsWith('.json')) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const filePath = join(dirPath, file);
|
|
154
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
155
|
+
results.push(JSON.parse(content) as T);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return results;
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Delete a file (synchronous)
|
|
166
|
+
*/
|
|
167
|
+
protected deleteSync(id: string, extension: string = 'json'): void {
|
|
168
|
+
const filePath = this.getFilePath(id, extension);
|
|
169
|
+
if (existsSync(filePath)) {
|
|
170
|
+
rmSync(filePath);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Delete a file (asynchronous)
|
|
176
|
+
*/
|
|
177
|
+
protected async delete(id: string, extension: string = 'json'): Promise<void> {
|
|
178
|
+
const filePath = this.getFilePath(id, extension);
|
|
179
|
+
try {
|
|
180
|
+
await rm(filePath);
|
|
181
|
+
} catch {
|
|
182
|
+
// File doesn't exist, ignore error
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check if file exists
|
|
188
|
+
*/
|
|
189
|
+
protected existsSync(id: string, extension: string = 'json'): boolean {
|
|
190
|
+
const filePath = this.getFilePath(id, extension);
|
|
191
|
+
return existsSync(filePath);
|
|
192
|
+
}
|
|
193
|
+
}
|