@lobehub/chat 1.141.8 → 1.141.10

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/apps/desktop/package.json +1 -0
  3. package/apps/desktop/src/main/controllers/LocalFileCtr.ts +279 -52
  4. package/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts +392 -0
  5. package/changelog/v1.json +18 -0
  6. package/package.json +1 -1
  7. package/packages/agent-runtime/src/core/InterventionChecker.ts +173 -0
  8. package/packages/agent-runtime/src/core/UsageCounter.ts +248 -0
  9. package/packages/agent-runtime/src/core/__tests__/InterventionChecker.test.ts +334 -0
  10. package/packages/agent-runtime/src/core/__tests__/UsageCounter.test.ts +873 -0
  11. package/packages/agent-runtime/src/core/__tests__/runtime.test.ts +32 -26
  12. package/packages/agent-runtime/src/core/index.ts +2 -0
  13. package/packages/agent-runtime/src/core/runtime.ts +31 -18
  14. package/packages/agent-runtime/src/types/instruction.ts +1 -1
  15. package/packages/agent-runtime/src/types/state.ts +3 -3
  16. package/packages/agent-runtime/src/types/usage.ts +34 -25
  17. package/packages/context-engine/src/index.ts +1 -0
  18. package/packages/context-engine/src/tools/ToolNameResolver.ts +2 -2
  19. package/packages/context-engine/src/tools/ToolsEngine.ts +37 -8
  20. package/packages/context-engine/src/tools/__tests__/ToolsEngine.test.ts +149 -5
  21. package/packages/context-engine/src/tools/__tests__/utils.test.ts +2 -2
  22. package/packages/context-engine/src/tools/index.ts +1 -0
  23. package/packages/context-engine/src/tools/types.ts +18 -3
  24. package/packages/context-engine/src/tools/utils.ts +4 -4
  25. package/packages/types/src/tool/builtin.ts +54 -1
  26. package/packages/types/src/tool/index.ts +1 -0
  27. package/packages/types/src/tool/intervention.ts +114 -0
  28. package/packages/types/src/user/settings/tool.ts +37 -0
  29. package/src/app/[variants]/(main)/discover/(list)/(home)/Client.tsx +2 -2
  30. package/src/app/[variants]/(main)/discover/(list)/(home)/HomePage.tsx +2 -2
  31. package/src/app/[variants]/(main)/discover/DiscoverRouter.tsx +2 -1
  32. package/src/features/Conversation/Messages/Assistant/Tool/Render/index.tsx +4 -2
  33. package/src/store/chat/slices/builtinTool/actions/{dalle.test.ts → __tests__/dalle.test.ts} +2 -5
  34. package/src/store/chat/slices/builtinTool/actions/__tests__/{localFile.test.ts → localSystem.test.ts} +4 -4
  35. package/src/store/chat/slices/builtinTool/actions/index.ts +2 -2
  36. package/src/store/chat/slices/builtinTool/actions/{localFile.ts → localSystem.ts} +183 -69
  37. package/src/store/electron/selectors/__tests__/desktopState.test.ts +3 -3
  38. package/src/store/electron/selectors/desktopState.ts +11 -2
  39. package/src/tools/local-system/Placeholder/ListFiles.tsx +10 -8
  40. package/src/tools/local-system/Placeholder/SearchFiles.tsx +12 -10
  41. package/src/tools/local-system/Placeholder/index.tsx +1 -1
  42. package/src/tools/local-system/Render/ReadLocalFile/ReadFileSkeleton.tsx +8 -18
  43. package/src/tools/local-system/Render/ReadLocalFile/ReadFileView.tsx +21 -6
  44. package/src/tools/local-system/Render/SearchFiles/Result.tsx +5 -4
  45. package/src/tools/local-system/Render/SearchFiles/SearchQuery/SearchView.tsx +4 -15
  46. package/src/tools/local-system/Render/SearchFiles/index.tsx +3 -2
  47. package/src/tools/local-system/type.ts +39 -0
  48. package/src/tools/local-system/Placeholder/ReadLocalFile.tsx +0 -9
  49. package/src/tools/local-system/Render/ReadLocalFile/style.ts +0 -37
  50. /package/src/store/chat/slices/builtinTool/actions/{search.test.ts → __tests__/search.test.ts} +0 -0
@@ -0,0 +1,392 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import type { App } from '@/core/App';
4
+
5
+ import LocalFileCtr from '../LocalFileCtr';
6
+
7
+ // Mock logger
8
+ vi.mock('@/utils/logger', () => ({
9
+ createLogger: () => ({
10
+ debug: vi.fn(),
11
+ info: vi.fn(),
12
+ warn: vi.fn(),
13
+ error: vi.fn(),
14
+ }),
15
+ }));
16
+
17
+ // Mock file-loaders
18
+ vi.mock('@lobechat/file-loaders', () => ({
19
+ SYSTEM_FILES_TO_IGNORE: ['.DS_Store', 'Thumbs.db'],
20
+ loadFile: vi.fn(),
21
+ }));
22
+
23
+ // Mock electron
24
+ vi.mock('electron', () => ({
25
+ shell: {
26
+ openPath: vi.fn(),
27
+ },
28
+ }));
29
+
30
+ // Mock fast-glob
31
+ vi.mock('fast-glob', () => ({
32
+ default: vi.fn(),
33
+ }));
34
+
35
+ // Mock node:fs/promises and node:fs
36
+ vi.mock('node:fs/promises', () => ({
37
+ stat: vi.fn(),
38
+ readdir: vi.fn(),
39
+ rename: vi.fn(),
40
+ access: vi.fn(),
41
+ writeFile: vi.fn(),
42
+ readFile: vi.fn(),
43
+ mkdir: vi.fn(),
44
+ }));
45
+
46
+ vi.mock('node:fs', () => ({
47
+ Stats: class Stats {},
48
+ constants: {
49
+ F_OK: 0,
50
+ },
51
+ stat: vi.fn(),
52
+ readdir: vi.fn(),
53
+ rename: vi.fn(),
54
+ access: vi.fn(),
55
+ writeFile: vi.fn(),
56
+ readFile: vi.fn(),
57
+ }));
58
+
59
+ // Mock FileSearchService
60
+ const mockSearchService = {
61
+ search: vi.fn(),
62
+ };
63
+
64
+ // Mock makeSureDirExist
65
+ vi.mock('@/utils/file-system', () => ({
66
+ makeSureDirExist: vi.fn(),
67
+ }));
68
+
69
+ const mockApp = {
70
+ getService: vi.fn(() => mockSearchService),
71
+ } as unknown as App;
72
+
73
+ describe('LocalFileCtr', () => {
74
+ let localFileCtr: LocalFileCtr;
75
+ let mockShell: any;
76
+ let mockFg: any;
77
+ let mockLoadFile: any;
78
+ let mockFsPromises: any;
79
+
80
+ beforeEach(async () => {
81
+ vi.clearAllMocks();
82
+
83
+ // Import mocks
84
+ mockShell = (await import('electron')).shell;
85
+ mockFg = (await import('fast-glob')).default;
86
+ mockLoadFile = (await import('@lobechat/file-loaders')).loadFile;
87
+ mockFsPromises = await import('node:fs/promises');
88
+
89
+ localFileCtr = new LocalFileCtr(mockApp);
90
+ });
91
+
92
+ describe('handleOpenLocalFile', () => {
93
+ it('should open file successfully', async () => {
94
+ vi.mocked(mockShell.openPath).mockResolvedValue('');
95
+
96
+ const result = await localFileCtr.handleOpenLocalFile({ path: '/test/file.txt' });
97
+
98
+ expect(result).toEqual({ success: true });
99
+ expect(mockShell.openPath).toHaveBeenCalledWith('/test/file.txt');
100
+ });
101
+
102
+ it('should return error when opening file fails', async () => {
103
+ const error = new Error('Failed to open');
104
+ vi.mocked(mockShell.openPath).mockRejectedValue(error);
105
+
106
+ const result = await localFileCtr.handleOpenLocalFile({ path: '/test/file.txt' });
107
+
108
+ expect(result).toEqual({ success: false, error: 'Failed to open' });
109
+ });
110
+ });
111
+
112
+ describe('handleOpenLocalFolder', () => {
113
+ it('should open directory when isDirectory is true', async () => {
114
+ vi.mocked(mockShell.openPath).mockResolvedValue('');
115
+
116
+ const result = await localFileCtr.handleOpenLocalFolder({
117
+ path: '/test/folder',
118
+ isDirectory: true,
119
+ });
120
+
121
+ expect(result).toEqual({ success: true });
122
+ expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
123
+ });
124
+
125
+ it('should open parent directory when isDirectory is false', async () => {
126
+ vi.mocked(mockShell.openPath).mockResolvedValue('');
127
+
128
+ const result = await localFileCtr.handleOpenLocalFolder({
129
+ path: '/test/folder/file.txt',
130
+ isDirectory: false,
131
+ });
132
+
133
+ expect(result).toEqual({ success: true });
134
+ expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
135
+ });
136
+
137
+ it('should return error when opening folder fails', async () => {
138
+ const error = new Error('Failed to open folder');
139
+ vi.mocked(mockShell.openPath).mockRejectedValue(error);
140
+
141
+ const result = await localFileCtr.handleOpenLocalFolder({
142
+ path: '/test/folder',
143
+ isDirectory: true,
144
+ });
145
+
146
+ expect(result).toEqual({ success: false, error: 'Failed to open folder' });
147
+ });
148
+ });
149
+
150
+ describe('readFile', () => {
151
+ it('should read file successfully with default location', async () => {
152
+ const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
153
+ vi.mocked(mockLoadFile).mockResolvedValue({
154
+ content: mockFileContent,
155
+ filename: 'test.txt',
156
+ fileType: 'txt',
157
+ createdTime: new Date('2024-01-01'),
158
+ modifiedTime: new Date('2024-01-02'),
159
+ });
160
+
161
+ const result = await localFileCtr.readFile({ path: '/test/file.txt' });
162
+
163
+ expect(result.filename).toBe('test.txt');
164
+ expect(result.fileType).toBe('txt');
165
+ expect(result.totalLineCount).toBe(5);
166
+ expect(result.content).toBe(mockFileContent);
167
+ });
168
+
169
+ it('should read file with custom location range', async () => {
170
+ const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
171
+ vi.mocked(mockLoadFile).mockResolvedValue({
172
+ content: mockFileContent,
173
+ filename: 'test.txt',
174
+ fileType: 'txt',
175
+ createdTime: new Date('2024-01-01'),
176
+ modifiedTime: new Date('2024-01-02'),
177
+ });
178
+
179
+ const result = await localFileCtr.readFile({ path: '/test/file.txt', loc: [1, 3] });
180
+
181
+ expect(result.content).toBe('line2\nline3');
182
+ expect(result.lineCount).toBe(2);
183
+ expect(result.totalLineCount).toBe(5);
184
+ });
185
+
186
+ it('should handle file read error', async () => {
187
+ vi.mocked(mockLoadFile).mockRejectedValue(new Error('File not found'));
188
+
189
+ const result = await localFileCtr.readFile({ path: '/test/missing.txt' });
190
+
191
+ expect(result.content).toContain('Error accessing or processing file');
192
+ expect(result.lineCount).toBe(0);
193
+ expect(result.charCount).toBe(0);
194
+ });
195
+ });
196
+
197
+ describe('readFiles', () => {
198
+ it('should read multiple files successfully', async () => {
199
+ vi.mocked(mockLoadFile).mockResolvedValue({
200
+ content: 'file content',
201
+ filename: 'test.txt',
202
+ fileType: 'txt',
203
+ createdTime: new Date('2024-01-01'),
204
+ modifiedTime: new Date('2024-01-02'),
205
+ });
206
+
207
+ const result = await localFileCtr.readFiles({
208
+ paths: ['/test/file1.txt', '/test/file2.txt'],
209
+ });
210
+
211
+ expect(result).toHaveLength(2);
212
+ expect(mockLoadFile).toHaveBeenCalledTimes(2);
213
+ });
214
+ });
215
+
216
+ describe('handleWriteFile', () => {
217
+ it('should write file successfully', async () => {
218
+ vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
219
+ vi.mocked(mockFsPromises.writeFile).mockResolvedValue(undefined);
220
+
221
+ const result = await localFileCtr.handleWriteFile({
222
+ path: '/test/file.txt',
223
+ content: 'test content',
224
+ });
225
+
226
+ expect(result).toEqual({ success: true });
227
+ });
228
+
229
+ it('should return error when path is empty', async () => {
230
+ const result = await localFileCtr.handleWriteFile({
231
+ path: '',
232
+ content: 'test content',
233
+ });
234
+
235
+ expect(result).toEqual({ success: false, error: 'Path cannot be empty' });
236
+ });
237
+
238
+ it('should return error when content is undefined', async () => {
239
+ const result = await localFileCtr.handleWriteFile({
240
+ path: '/test/file.txt',
241
+ content: undefined as any,
242
+ });
243
+
244
+ expect(result).toEqual({ success: false, error: 'Content cannot be empty' });
245
+ });
246
+
247
+ it('should handle write error', async () => {
248
+ vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
249
+ vi.mocked(mockFsPromises.writeFile).mockRejectedValue(new Error('Write failed'));
250
+
251
+ const result = await localFileCtr.handleWriteFile({
252
+ path: '/test/file.txt',
253
+ content: 'test content',
254
+ });
255
+
256
+ expect(result).toEqual({ success: false, error: 'Failed to write file: Write failed' });
257
+ });
258
+ });
259
+
260
+ describe('handleRenameFile', () => {
261
+ it('should rename file successfully', async () => {
262
+ vi.mocked(mockFsPromises.rename).mockResolvedValue(undefined);
263
+
264
+ const result = await localFileCtr.handleRenameFile({
265
+ path: '/test/old.txt',
266
+ newName: 'new.txt',
267
+ });
268
+
269
+ expect(result).toEqual({ success: true, newPath: '/test/new.txt' });
270
+ expect(mockFsPromises.rename).toHaveBeenCalledWith('/test/old.txt', '/test/new.txt');
271
+ });
272
+
273
+ it('should skip rename when paths are identical', async () => {
274
+ const result = await localFileCtr.handleRenameFile({
275
+ path: '/test/file.txt',
276
+ newName: 'file.txt',
277
+ });
278
+
279
+ expect(result).toEqual({ success: true, newPath: '/test/file.txt' });
280
+ expect(mockFsPromises.rename).not.toHaveBeenCalled();
281
+ });
282
+
283
+ it('should reject invalid new name with path separators', async () => {
284
+ const result = await localFileCtr.handleRenameFile({
285
+ path: '/test/old.txt',
286
+ newName: '../new.txt',
287
+ });
288
+
289
+ expect(result.success).toBe(false);
290
+ expect(result.error).toContain('Invalid new name');
291
+ });
292
+
293
+ it('should reject invalid new name with special characters', async () => {
294
+ const result = await localFileCtr.handleRenameFile({
295
+ path: '/test/old.txt',
296
+ newName: 'new:file.txt',
297
+ });
298
+
299
+ expect(result.success).toBe(false);
300
+ expect(result.error).toContain('Invalid new name');
301
+ });
302
+
303
+ it('should handle file not found error', async () => {
304
+ const error: any = new Error('File not found');
305
+ error.code = 'ENOENT';
306
+ vi.mocked(mockFsPromises.rename).mockRejectedValue(error);
307
+
308
+ const result = await localFileCtr.handleRenameFile({
309
+ path: '/test/old.txt',
310
+ newName: 'new.txt',
311
+ });
312
+
313
+ expect(result.success).toBe(false);
314
+ expect(result.error).toContain('File or directory not found');
315
+ });
316
+
317
+ it('should handle file already exists error', async () => {
318
+ const error: any = new Error('File exists');
319
+ error.code = 'EEXIST';
320
+ vi.mocked(mockFsPromises.rename).mockRejectedValue(error);
321
+
322
+ const result = await localFileCtr.handleRenameFile({
323
+ path: '/test/old.txt',
324
+ newName: 'new.txt',
325
+ });
326
+
327
+ expect(result.success).toBe(false);
328
+ expect(result.error).toContain('already exists');
329
+ });
330
+ });
331
+
332
+ describe('handleLocalFilesSearch', () => {
333
+ it('should search files successfully', async () => {
334
+ const mockResults = [
335
+ {
336
+ name: 'test.txt',
337
+ path: '/test/test.txt',
338
+ isDirectory: false,
339
+ size: 100,
340
+ type: 'txt',
341
+ },
342
+ ];
343
+ mockSearchService.search.mockResolvedValue(mockResults);
344
+
345
+ const result = await localFileCtr.handleLocalFilesSearch({ keywords: 'test' });
346
+
347
+ expect(result).toEqual(mockResults);
348
+ expect(mockSearchService.search).toHaveBeenCalledWith('test', { limit: 30 });
349
+ });
350
+
351
+ it('should return empty array on search error', async () => {
352
+ mockSearchService.search.mockRejectedValue(new Error('Search failed'));
353
+
354
+ const result = await localFileCtr.handleLocalFilesSearch({ keywords: 'test' });
355
+
356
+ expect(result).toEqual([]);
357
+ });
358
+ });
359
+
360
+ describe('handleGlobFiles', () => {
361
+ it('should glob files successfully', async () => {
362
+ const mockFiles = [
363
+ { path: '/test/file1.txt', stats: { mtime: new Date('2024-01-02') } },
364
+ { path: '/test/file2.txt', stats: { mtime: new Date('2024-01-01') } },
365
+ ];
366
+ vi.mocked(mockFg).mockResolvedValue(mockFiles);
367
+
368
+ const result = await localFileCtr.handleGlobFiles({
369
+ pattern: '*.txt',
370
+ path: '/test',
371
+ });
372
+
373
+ expect(result.success).toBe(true);
374
+ expect(result.files).toEqual(['/test/file1.txt', '/test/file2.txt']);
375
+ expect(result.total_files).toBe(2);
376
+ });
377
+
378
+ it('should handle glob error', async () => {
379
+ vi.mocked(mockFg).mockRejectedValue(new Error('Glob failed'));
380
+
381
+ const result = await localFileCtr.handleGlobFiles({
382
+ pattern: '*.txt',
383
+ });
384
+
385
+ expect(result).toEqual({
386
+ success: false,
387
+ files: [],
388
+ total_files: 0,
389
+ });
390
+ });
391
+ });
392
+ });
package/changelog/v1.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "fixes": [
5
+ "Loadmore not work & navbar not show in pwa."
6
+ ]
7
+ },
8
+ "date": "2025-10-23",
9
+ "version": "1.141.10"
10
+ },
11
+ {
12
+ "children": {
13
+ "improvements": [
14
+ "Improve local system tools render."
15
+ ]
16
+ },
17
+ "date": "2025-10-23",
18
+ "version": "1.141.9"
19
+ },
2
20
  {
3
21
  "children": {
4
22
  "improvements": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.141.8",
3
+ "version": "1.141.10",
4
4
  "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -0,0 +1,173 @@
1
+ import type {
2
+ ArgumentMatcher,
3
+ HumanInterventionPolicy,
4
+ HumanInterventionRule,
5
+ ShouldInterveneParams,
6
+ } from '@lobechat/types';
7
+
8
+ /**
9
+ * Intervention Checker
10
+ * Determines whether a tool call requires human intervention
11
+ */
12
+ export class InterventionChecker {
13
+ /**
14
+ * Check if a tool call requires intervention
15
+ *
16
+ * @param params - Parameters object containing config, toolArgs, confirmedHistory, and toolKey
17
+ * @returns Policy to apply
18
+ */
19
+ static shouldIntervene(params: ShouldInterveneParams): HumanInterventionPolicy {
20
+ const { config, toolArgs = {}, confirmedHistory = [], toolKey } = params;
21
+
22
+ // No config means never intervene (auto-execute)
23
+ if (!config) return 'never';
24
+
25
+ // Simple policy string
26
+ if (typeof config === 'string') {
27
+ // For 'first' policy, check if already confirmed
28
+ if (config === 'first' && toolKey && confirmedHistory.includes(toolKey)) {
29
+ return 'never';
30
+ }
31
+ return config;
32
+ }
33
+
34
+ // Array of rules - find first matching rule
35
+ for (const rule of config) {
36
+ if (this.matchesRule(rule, toolArgs)) {
37
+ const policy = rule.policy;
38
+
39
+ // For 'first' policy, check if already confirmed
40
+ if (policy === 'first' && toolKey && confirmedHistory.includes(toolKey)) {
41
+ return 'never';
42
+ }
43
+
44
+ return policy;
45
+ }
46
+ }
47
+
48
+ // No rule matched - default to always for safety
49
+ return 'always';
50
+ }
51
+
52
+ /**
53
+ * Check if tool arguments match a rule
54
+ *
55
+ * @param rule - Rule to check
56
+ * @param toolArgs - Tool call arguments
57
+ * @returns true if matches
58
+ */
59
+ private static matchesRule(rule: HumanInterventionRule, toolArgs: Record<string, any>): boolean {
60
+ // No match criteria means it's a default rule
61
+ if (!rule.match) return true;
62
+
63
+ // Check each parameter matcher
64
+ for (const [paramName, matcher] of Object.entries(rule.match)) {
65
+ const paramValue = toolArgs[paramName];
66
+
67
+ // Parameter not present in args
68
+ if (paramValue === undefined) return false;
69
+
70
+ // Check if value matches
71
+ if (!this.matchesArgument(matcher, paramValue)) {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ return true;
77
+ }
78
+
79
+ /**
80
+ * Check if a parameter value matches the matcher
81
+ *
82
+ * @param matcher - Argument matcher
83
+ * @param value - Parameter value
84
+ * @returns true if matches
85
+ */
86
+ private static matchesArgument(matcher: ArgumentMatcher, value: any): boolean {
87
+ const strValue = String(value);
88
+
89
+ // Simple string matcher
90
+ if (typeof matcher === 'string') {
91
+ return this.matchPattern(matcher, strValue);
92
+ }
93
+
94
+ // Complex matcher with type
95
+ const { pattern, type } = matcher;
96
+
97
+ switch (type) {
98
+ case 'exact': {
99
+ return strValue === pattern;
100
+ }
101
+ case 'prefix': {
102
+ return strValue.startsWith(pattern);
103
+ }
104
+ case 'wildcard': {
105
+ return this.matchPattern(pattern, strValue);
106
+ }
107
+ case 'regex': {
108
+ return new RegExp(pattern).test(strValue);
109
+ }
110
+ default: {
111
+ return false;
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Match wildcard pattern (supports * wildcard)
118
+ *
119
+ * @param pattern - Pattern with wildcards
120
+ * @param value - Value to match
121
+ * @returns true if matches
122
+ */
123
+ private static matchPattern(pattern: string, value: string): boolean {
124
+ // Check for colon-based prefix matching (e.g., "git add:*")
125
+ if (pattern.includes(':')) {
126
+ const [prefix, suffix] = pattern.split(':');
127
+ if (suffix === '*') {
128
+ return value.startsWith(prefix + ':') || value === prefix;
129
+ }
130
+ }
131
+
132
+ // Convert wildcard pattern to regex
133
+ const regexPattern = pattern
134
+ .replaceAll(/[$()+.?[\\\]^{|}]/g, '\\$&') // Escape special chars
135
+ .replaceAll('*', '.*'); // Replace * with .*
136
+
137
+ return new RegExp(`^${regexPattern}$`).test(value);
138
+ }
139
+
140
+ /**
141
+ * Generate tool key from identifier and API name
142
+ *
143
+ * @param identifier - Tool identifier
144
+ * @param apiName - API name
145
+ * @param argsHash - Optional hash of arguments
146
+ * @returns Tool key in format "identifier/apiName" or "identifier/apiName#hash"
147
+ */
148
+ static generateToolKey(identifier: string, apiName: string, argsHash?: string): string {
149
+ const baseKey = `${identifier}/${apiName}`;
150
+ return argsHash ? `${baseKey}#${argsHash}` : baseKey;
151
+ }
152
+
153
+ /**
154
+ * Generate simple hash of arguments for 'once' policy
155
+ *
156
+ * @param args - Tool call arguments
157
+ * @returns Hash string
158
+ */
159
+ static hashArguments(args: Record<string, any>): string {
160
+ const sortedKeys = Object.keys(args).sort();
161
+ const str = sortedKeys.map((key) => `${key}=${JSON.stringify(args[key])}`).join('&');
162
+
163
+ // Simple hash function
164
+ let hash = 0;
165
+ for (let i = 0; i < str.length; i++) {
166
+ const char = str.charCodeAt(i);
167
+ hash = (hash << 5) - hash + char;
168
+ hash = hash & hash; // Convert to 32-bit integer
169
+ }
170
+
171
+ return Math.abs(hash).toString(36);
172
+ }
173
+ }