diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index 0d687c3..81f7721 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -8,7 +8,7 @@ import { fromString as aiFunctionFromString } from "Enums/AIFunction"; import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition"; import type { ConversationContent } from "Conversations/ConversationContent"; import { Role } from "Enums/Role"; -import type { RawMessageStreamEvent, ContentBlockParam, Tool } from '@anthropic-ai/sdk/resources/messages'; +import type { RawMessageStreamEvent, ContentBlockParam, Tool, TextBlockParam, ToolUnion, WebSearchTool20250305 } from '@anthropic-ai/sdk/resources/messages'; import { Exception } from "Helpers/Exception"; import { MimeType, toMimeType } from "Enums/MimeType"; import { isTextFile } from "Enums/FileType"; @@ -47,17 +47,33 @@ export class Claude extends BaseAIClass { await this.aiFileService.refreshCache(); } - const systemPrompt = await this.buildSystemPrompt(); + // Build system prompt and convert to array with cache control + const systemPromptText = await this.buildSystemPrompt(); + const systemPrompt: TextBlockParam[] = [ + { + type: "text", + text: systemPromptText, + cache_control: { type: "ephemeral" } + } + ]; - const messages = await this.extractContents(conversation.contents); + const messages = this.addCacheControlToMessages( + await this.extractContents(conversation.contents)); - const tools = [{ + const webSearchTool: WebSearchTool20250305 = { type: "web_search_20250305", name: "web_search", max_uses: 5 - }, ...this.mapFunctionDefinitions( - this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions) - )]; + }; + + let tools: ToolUnion[] = [ + webSearchTool, + ...this.mapFunctionDefinitions( + this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions) + ) + ]; + + tools = this.addCacheControlToTools(tools); const requestBody = { model: this.settingsService.settings.model, @@ -303,4 +319,53 @@ export class Claude extends BaseAIClass { private isSupportedMimeType(mimeType: MimeType): boolean { return this.SUPPORTED_MIMETYPES.includes(mimeType); } + + // Adds cache control to the last tool in the tools array. + private addCacheControlToTools(tools: ToolUnion[]): ToolUnion[] { + if (tools.length === 0) { + return tools; + } + + const cachedTools = [...tools]; + const lastIndex = cachedTools.length - 1; + + cachedTools[lastIndex] = { + ...cachedTools[lastIndex], + cache_control: { type: "ephemeral" } + }; + + return cachedTools; + } + + // Adds cache control to the last content block of the second-to-last message. + private addCacheControlToMessages( + messages: { role: Role; content: ContentBlockParam[]; }[] + ): { role: Role; content: ContentBlockParam[]; }[] { + if (messages.length < 2) { + return messages; + } + + const cachedMessages = messages.map(msg => ({ + ...msg, + content: [...msg.content] + })); + + const secondToLastIndex = cachedMessages.length - 2; + const secondToLastMessage = cachedMessages[secondToLastIndex]; + + const contentLength = secondToLastMessage.content.length; + if (contentLength === 0) { + return messages; + } + + const lastContentIndex = contentLength - 1; + const lastContentBlock = secondToLastMessage.content[lastContentIndex]; + + secondToLastMessage.content[lastContentIndex] = { + ...lastContentBlock, + cache_control: { type: "ephemeral" } + } as ContentBlockParam; + + return cachedMessages; + } } \ No newline at end of file diff --git a/AIClasses/FunctionDefinitions/AIFunctionResponse.ts b/AIClasses/FunctionDefinitions/AIFunctionResponse.ts index 112ecc6..467c1ea 100644 --- a/AIClasses/FunctionDefinitions/AIFunctionResponse.ts +++ b/AIClasses/FunctionDefinitions/AIFunctionResponse.ts @@ -13,7 +13,21 @@ export class AIFunctionResponse { **CRITICAL:** Immediately stop all further actions and consult with the user`; - public static readonly UserSuggestionMessage: string = "The user has rejected the change with the following suggestion: "; + public static readonly UserSuggestionMessage: string = `**USER MODIFICATION REQUEST:** + + The user has reviewed your proposed action and provided a modification or alternative direction. + + **Critical Instructions:** + 1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously + 2. The user may want to: + - Adjust the SAME action with different parameters (e.g., write to a different file) + - Change to a DIFFERENT action entirely (e.g., delete instead of write) + - Add context or constraints you didn't initially consider + 3. Carefully analyze the user's suggestion below to understand their true intent + 4. Acknowledge their feedback and explain how you'll adjust your approach + 5. Then proceed with the modified action that aligns with their guidance + + **User's Suggestion:**`; constructor(name: AIFunction, response: object, toolId?: string) { this.name = name; diff --git a/Modals/HelpModalSvelte.svelte b/Modals/HelpModalSvelte.svelte index c7fc980..5d5b6de 100644 --- a/Modals/HelpModalSvelte.svelte +++ b/Modals/HelpModalSvelte.svelte @@ -182,7 +182,7 @@ {@html content} {#if selectedTopic === 1} { }); }); + describe('addCacheControlToTools', () => { + it('should add cache control to the last tool in the array', () => { + const tools = [ + { + name: 'search_vault_files', + description: 'Search for files', + input_schema: { + type: 'object' as const, + properties: { query: { type: 'string' } } + } + }, + { + name: 'read_file', + description: 'Read a file', + input_schema: { + type: 'object' as const, + properties: { path: { type: 'string' } } + } + } + ]; + + const result = (claude as any).addCacheControlToTools(tools); + + expect(result).toHaveLength(2); + expect(result[0]).not.toHaveProperty('cache_control'); + expect(result[1]).toHaveProperty('cache_control'); + expect(result[1].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should handle single tool array', () => { + const tools = [ + { + name: 'search_vault_files', + description: 'Search for files', + input_schema: { + type: 'object' as const, + properties: { query: { type: 'string' } } + } + } + ]; + + const result = (claude as any).addCacheControlToTools(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('cache_control'); + expect(result[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should handle empty tools array', () => { + const tools: any[] = []; + + const result = (claude as any).addCacheControlToTools(tools); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('should not mutate the original tools array', () => { + const tools = [ + { + name: 'search_vault_files', + description: 'Search for files', + input_schema: { + type: 'object' as const, + properties: { query: { type: 'string' } } + } + } + ]; + + const result = (claude as any).addCacheControlToTools(tools); + + // Original array should not have cache_control + expect(tools[0]).not.toHaveProperty('cache_control'); + // Result should have cache_control + expect(result[0]).toHaveProperty('cache_control'); + }); + + it('should add cache control to web_search tool', () => { + const tools = [ + { + type: 'web_search_20250305', + name: 'web_search', + max_uses: 5 + } + ]; + + const result = (claude as any).addCacheControlToTools(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('cache_control'); + expect(result[0].cache_control).toEqual({ type: 'ephemeral' }); + expect(result[0].type).toBe('web_search_20250305'); + expect(result[0].name).toBe('web_search'); + }); + }); + + describe('addCacheControlToMessages', () => { + it('should add cache control to the last content block of the second-to-last message', () => { + const messages = [ + { + role: Role.User, + content: [{ type: 'text' as const, text: 'First message' }] + }, + { + role: Role.Assistant, + content: [{ type: 'text' as const, text: 'Second message' }] + }, + { + role: Role.User, + content: [{ type: 'text' as const, text: 'Third message' }] + } + ]; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(3); + // First message should not have cache control + expect(result[0].content[0]).not.toHaveProperty('cache_control'); + // Second message (second-to-last) should have cache control + expect(result[1].content[0]).toHaveProperty('cache_control'); + expect(result[1].content[0].cache_control).toEqual({ type: 'ephemeral' }); + // Third message (last) should not have cache control + expect(result[2].content[0]).not.toHaveProperty('cache_control'); + }); + + it('should add cache control to the last content block when message has multiple blocks', () => { + const messages = [ + { + role: Role.User, + content: [ + { type: 'text' as const, text: 'First block' }, + { type: 'text' as const, text: 'Second block' }, + { type: 'text' as const, text: 'Third block' } + ] + }, + { + role: Role.Assistant, + content: [{ type: 'text' as const, text: 'Response' }] + } + ]; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(2); + // First message, last block should have cache control + expect(result[0].content[0]).not.toHaveProperty('cache_control'); + expect(result[0].content[1]).not.toHaveProperty('cache_control'); + expect(result[0].content[2]).toHaveProperty('cache_control'); + expect(result[0].content[2].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should return messages unchanged when there are fewer than 2 messages', () => { + const singleMessage = [ + { + role: Role.User, + content: [{ type: 'text' as const, text: 'Only message' }] + } + ]; + + const result = (claude as any).addCacheControlToMessages(singleMessage); + + expect(result).toHaveLength(1); + expect(result[0].content[0]).not.toHaveProperty('cache_control'); + }); + + it('should return empty array when messages array is empty', () => { + const messages: any[] = []; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('should handle messages with empty content blocks', () => { + const messages = [ + { + role: Role.User, + content: [] + }, + { + role: Role.Assistant, + content: [{ type: 'text' as const, text: 'Response' }] + } + ]; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(2); + // Should return original messages since second-to-last has no content + expect(result).toEqual(messages); + }); + + it('should not mutate the original messages array', () => { + const messages = [ + { + role: Role.User, + content: [{ type: 'text' as const, text: 'First' }] + }, + { + role: Role.Assistant, + content: [{ type: 'text' as const, text: 'Second' }] + } + ]; + + const originalFirstContent = messages[0].content[0]; + const result = (claude as any).addCacheControlToMessages(messages); + + // Original should not have cache_control + expect(originalFirstContent).not.toHaveProperty('cache_control'); + // Result should have cache_control on second-to-last message + expect(result[0].content[0]).toHaveProperty('cache_control'); + }); + + it('should handle messages with tool_use and tool_result blocks', () => { + const messages = [ + { + role: Role.User, + content: [{ type: 'text' as const, text: 'Search for files' }] + }, + { + role: Role.Assistant, + content: [ + { type: 'text' as const, text: 'Let me search' }, + { type: 'tool_use' as const, id: 'call_1', name: 'search', input: {} } + ] + }, + { + role: Role.User, + content: [ + { type: 'tool_result' as const, tool_use_id: 'call_1', content: '["file1.txt"]' } + ] + } + ]; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(3); + // Second-to-last message's last content block should have cache control + expect(result[1].content[1]).toHaveProperty('cache_control'); + expect(result[1].content[1].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should work with exactly 2 messages', () => { + const messages = [ + { + role: Role.User, + content: [{ type: 'text' as const, text: 'Hello' }] + }, + { + role: Role.Assistant, + content: [{ type: 'text' as const, text: 'Hi' }] + } + ]; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(2); + // First message (second-to-last when length=2) should have cache control + expect(result[0].content[0]).toHaveProperty('cache_control'); + expect(result[0].content[0].cache_control).toEqual({ type: 'ephemeral' }); + // Second message should not have cache control + expect(result[1].content[0]).not.toHaveProperty('cache_control'); + }); + }); + describe('streamRequest', () => { - it('should call streamingService with correct parameters', async () => { + it('should call streamingService with correct parameters including cache control', async () => { const conversation = new Conversation(); conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Test message' })); @@ -847,7 +1113,13 @@ describe('Claude', () => { expect.objectContaining({ model: 'claude-opus-4-20250514', max_tokens: 16384, - system: 'System instruction\n\nUser instruction', + system: [ + { + type: 'text', + text: 'System instruction\n\nUser instruction', + cache_control: { type: 'ephemeral' } + } + ], messages: expect.any(Array), tools: expect.any(Array), stream: true diff --git a/package-lock.json b/package-lock.json index 1fcdc11..37e7bb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", - "@testing-library/svelte": "^5.3.0", + "@testing-library/svelte": "^5.3.1", "@types/express": "^5.0.6", "@types/node": "^25.0.3", "@types/path-browserify": "^1.0.3", @@ -54,7 +54,7 @@ "eslint-plugin-obsidianmd": "^0.1.9", "happy-dom": "^20.0.11", "obsidian": "latest", - "svelte": "^5.46.0", + "svelte": "^5.46.1", "svelte-check": "^4.3.5", "svelte-preprocess": "^6.0.3", "tslib": "2.8.1", @@ -1462,9 +1462,9 @@ } }, "node_modules/@testing-library/svelte": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.0.tgz", - "integrity": "sha512-N1b+OAZNF34iMmtTbj5TyE0Ff+NJ/Mp6E483qOIeyzRUKOTcj2N5jEEl56RJeNq5jeQ4kZp/oMA0hCybXydrtQ==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", "dev": true, "license": "MIT", "dependencies": { @@ -8872,9 +8872,9 @@ } }, "node_modules/svelte": { - "version": "5.46.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.0.tgz", - "integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==", + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz", + "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", "dev": true, "license": "MIT", "dependencies": { @@ -9125,9 +9125,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.2.0.tgz", + "integrity": "sha512-L6f5oQRAoLU1RwXz0Ab9mxsE7LtxeVB6AIR1lpkZMsOyg/JXeaxBaXa/FVCBZyNr9S9I4wkHrlZTklX+im+WMw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 038b1ab..48e52bd 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.2", - "@testing-library/svelte": "^5.3.0", + "@testing-library/svelte": "^5.3.1", "@types/express": "^5.0.6", "@types/node": "^25.0.3", "@types/path-browserify": "^1.0.3", @@ -36,7 +36,7 @@ "eslint-plugin-obsidianmd": "^0.1.9", "happy-dom": "^20.0.11", "obsidian": "latest", - "svelte": "^5.46.0", + "svelte": "^5.46.1", "svelte-check": "^4.3.5", "svelte-preprocess": "^6.0.3", "tslib": "2.8.1",