From 517a67ced75b2a44b80e5ea71c19945739074fab Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Mon, 16 Mar 2026 15:48:14 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20respect=20agent-level=20m?= =?UTF-8?q?emory=20config=20priority=20over=20user=20settings=20(#13018)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update skills * 🐛 fix: respect agent-level memory config priority over user settings Agent chatConfig.memory.enabled now takes priority. Falls back to user-level memory setting when agent config is absent. Co-Authored-By: Claude Opus 4.6 (1M context) * 🐛 fix: resolve tsgo type error in memory integration test Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../changelog-example/db-migration.md | 4 +- .../reference/patch-release-scenarios.md | 1 + .../ChatInput/ActionBar/Memory/Controls.tsx | 11 +- .../ChatInput/ActionBar/Memory/index.tsx | 9 +- .../ActionBar/Memory/useMemoryEnabled.ts | 17 +++ src/helpers/toolEngineering/index.test.ts | 11 ++ src/helpers/toolEngineering/index.ts | 6 +- .../__tests__/execAgent.memory.test.ts | 144 ++++++++++++++++++ src/server/services/aiAgent/index.ts | 8 +- 9 files changed, 195 insertions(+), 16 deletions(-) create mode 100644 src/features/ChatInput/ActionBar/Memory/useMemoryEnabled.ts create mode 100644 src/server/services/aiAgent/__tests__/execAgent.memory.test.ts diff --git a/.agents/skills/version-release/reference/changelog-example/db-migration.md b/.agents/skills/version-release/reference/changelog-example/db-migration.md index 144c8c08cf..db37005af4 100644 --- a/.agents/skills/version-release/reference/changelog-example/db-migration.md +++ b/.agents/skills/version-release/reference/changelog-example/db-migration.md @@ -15,4 +15,6 @@ This release includes a **database schema migration** involving **5 new tables** - The migration runs automatically on application startup - No manual intervention required -The migration owner: @\[pr-author] — responsible for this database schema change, reach out for any migration-related issues. +The migration owner: @{pr-author} — responsible for this database schema change, reach out for any migration-related issues. + +> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view --json author --jq '.author.login'` or `git log` commit author. Do NOT hardcode a username. diff --git a/.agents/skills/version-release/reference/patch-release-scenarios.md b/.agents/skills/version-release/reference/patch-release-scenarios.md index 42f63d8b09..80817392d9 100644 --- a/.agents/skills/version-release/reference/patch-release-scenarios.md +++ b/.agents/skills/version-release/reference/patch-release-scenarios.md @@ -105,6 +105,7 @@ git push -u origin release/db-migration-{name} - What tables/columns are added, modified, or removed - Whether the migration is backwards-compatible - Any action required by self-hosted users + - **Migration owner**: Use the actual PR author (retrieve via `gh pr view --json author --jq '.author.login'` or `git log` commit author), never hardcode a username 3. **Create PR to main** with the migration changelog as the PR body diff --git a/src/features/ChatInput/ActionBar/Memory/Controls.tsx b/src/features/ChatInput/ActionBar/Memory/Controls.tsx index 9a3b930b2a..f657480006 100644 --- a/src/features/ChatInput/ActionBar/Memory/Controls.tsx +++ b/src/features/ChatInput/ActionBar/Memory/Controls.tsx @@ -14,6 +14,7 @@ import { chatConfigByIdSelectors } from '@/store/agent/selectors'; import { useAgentId } from '../../hooks/useAgentId'; import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig'; +import { useMemoryEnabled } from './useMemoryEnabled'; const MEMORY_EFFORT_LEVELS: readonly UserMemoryEffort[] = ['low', 'medium', 'high']; @@ -61,9 +62,7 @@ interface ToggleOption { const ToggleItem = memo(({ value, description, icon, label }) => { const agentId = useAgentId(); const { updateAgentChatConfig } = useUpdateAgentConfig(); - const isEnabled = useAgentStore((s) => - chatConfigByIdSelectors.isMemoryToolEnabledById(agentId)(s), - ); + const isEnabled = useMemoryEnabled(agentId); const isActive = value === 'on' ? isEnabled : !isEnabled; @@ -92,10 +91,8 @@ const Controls = memo(() => { const { t } = useTranslation('chat'); const agentId = useAgentId(); const { updateAgentChatConfig } = useUpdateAgentConfig(); - const [isEnabled, effort] = useAgentStore((s) => [ - chatConfigByIdSelectors.isMemoryToolEnabledById(agentId)(s), - chatConfigByIdSelectors.getMemoryToolEffortById(agentId)(s), - ]); + const isEnabled = useMemoryEnabled(agentId); + const effort = useAgentStore((s) => chatConfigByIdSelectors.getMemoryToolEffortById(agentId)(s)); const toggleOptions: ToggleOption[] = [ { diff --git a/src/features/ChatInput/ActionBar/Memory/index.tsx b/src/features/ChatInput/ActionBar/Memory/index.tsx index 3dd12585fb..cff46eed31 100644 --- a/src/features/ChatInput/ActionBar/Memory/index.tsx +++ b/src/features/ChatInput/ActionBar/Memory/index.tsx @@ -6,21 +6,20 @@ import { useTranslation } from 'react-i18next'; import { useIsMobile } from '@/hooks/useIsMobile'; import { useAgentStore } from '@/store/agent'; -import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors'; +import { agentByIdSelectors } from '@/store/agent/selectors'; import { useAgentId } from '../../hooks/useAgentId'; import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig'; import Action from '../components/Action'; import Controls from './Controls'; +import { useMemoryEnabled } from './useMemoryEnabled'; const Memory = memo(() => { const { t } = useTranslation('chat'); const agentId = useAgentId(); const { updateAgentChatConfig } = useUpdateAgentConfig(); - const [isLoading, isEnabled] = useAgentStore((s) => [ - agentByIdSelectors.isAgentConfigLoadingById(agentId)(s), - chatConfigByIdSelectors.isMemoryToolEnabledById(agentId)(s), - ]); + const isLoading = useAgentStore((s) => agentByIdSelectors.isAgentConfigLoadingById(agentId)(s)); + const isEnabled = useMemoryEnabled(agentId); const isMobile = useIsMobile(); if (isLoading) return ; diff --git a/src/features/ChatInput/ActionBar/Memory/useMemoryEnabled.ts b/src/features/ChatInput/ActionBar/Memory/useMemoryEnabled.ts new file mode 100644 index 0000000000..f5eea8faf1 --- /dev/null +++ b/src/features/ChatInput/ActionBar/Memory/useMemoryEnabled.ts @@ -0,0 +1,17 @@ +import { useAgentStore } from '@/store/agent'; +import { chatConfigByIdSelectors } from '@/store/agent/selectors'; +import { useUserStore } from '@/store/user'; +import { settingsSelectors } from '@/store/user/selectors'; + +/** + * Returns the effective memory enabled state for an agent. + * Agent-level config takes priority; falls back to user-level setting. + */ +export const useMemoryEnabled = (agentId: string): boolean => { + const agentMemoryEnabled = useAgentStore( + (s) => chatConfigByIdSelectors.getMemoryToolConfigById(agentId)(s)?.enabled, + ); + const userMemoryEnabled = useUserStore(settingsSelectors.memoryEnabled); + + return agentMemoryEnabled ?? userMemoryEnabled; +}; diff --git a/src/helpers/toolEngineering/index.test.ts b/src/helpers/toolEngineering/index.test.ts index 83f53d0a9d..3b225f52d6 100644 --- a/src/helpers/toolEngineering/index.test.ts +++ b/src/helpers/toolEngineering/index.test.ts @@ -95,12 +95,23 @@ vi.mock('@/store/agent/selectors', () => ({ hasEnabledKnowledgeBases: () => false, }, agentChatConfigSelectors: { + currentChatConfig: () => ({}), isCloudSandboxEnabled: () => false, isLocalSystemEnabled: () => false, isMemoryToolEnabled: () => false, }, })); +vi.mock('@/store/user', () => ({ + useUserStore: { getState: () => ({}) }, +})); + +vi.mock('@/store/user/selectors', () => ({ + settingsSelectors: { + memoryEnabled: () => false, + }, +})); + let mockUseApplicationBuiltinSearchTool = true; vi.mock('@/helpers/getSearchConfig', () => ({ diff --git a/src/helpers/toolEngineering/index.ts b/src/helpers/toolEngineering/index.ts index 6770e842ee..bfa426cada 100644 --- a/src/helpers/toolEngineering/index.ts +++ b/src/helpers/toolEngineering/index.ts @@ -21,6 +21,8 @@ import { lobehubSkillStoreSelectors, pluginSelectors, } from '@/store/tool/selectors'; +import { useUserStore } from '@/store/user'; +import { settingsSelectors } from '@/store/user/selectors'; import { getSearchConfig } from '../getSearchConfig'; import { isCanUseFC } from '../isCanUseFC'; @@ -122,7 +124,9 @@ export const createAgentToolsEngine = ( agentChatConfigSelectors.isCloudSandboxEnabled(agentState), [KnowledgeBaseManifest.identifier]: agentSelectors.hasEnabledKnowledgeBases(agentState), [LocalSystemManifest.identifier]: agentChatConfigSelectors.isLocalSystemEnabled(agentState), - [MemoryManifest.identifier]: agentChatConfigSelectors.isMemoryToolEnabled(agentState), + [MemoryManifest.identifier]: + agentChatConfigSelectors.currentChatConfig(agentState).memory?.enabled ?? + settingsSelectors.memoryEnabled(useUserStore.getState()), [WebBrowsingManifest.identifier]: searchConfig.useApplicationBuiltinSearchTool, }, }), diff --git a/src/server/services/aiAgent/__tests__/execAgent.memory.test.ts b/src/server/services/aiAgent/__tests__/execAgent.memory.test.ts new file mode 100644 index 0000000000..904db6b498 --- /dev/null +++ b/src/server/services/aiAgent/__tests__/execAgent.memory.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment node +/** + * Integration tests for memory enabled priority in execAgent. + * + * Verifies that agent-level memory config takes priority over user-level setting, + * and falls back to user setting when agent config is absent. + */ +import type { LobeChatDatabase } from '@lobechat/database'; +import { agents, userSettings } from '@lobechat/database/schemas'; +import { getTestDB } from '@lobechat/database/test-utils'; +import { eq } from 'drizzle-orm'; +import OpenAI from 'openai'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { inMemoryAgentStateManager } from '@/server/modules/AgentRuntime/InMemoryAgentStateManager'; +import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager'; + +import { + createMockResponsesAPIStream, + waitForOperationComplete, +} from '../../../routers/lambda/__tests__/integration/aiAgent/helpers'; +import { + cleanupTestUser, + createTestUser, +} from '../../../routers/lambda/__tests__/integration/setup'; +import { aiAgentRouter } from '../../../routers/lambda/aiAgent'; + +process.env.OPENAI_API_KEY = 'sk-test-fake-api-key-for-testing'; + +let testDB: LobeChatDatabase; +vi.mock('@/database/core/db-adaptor', () => ({ + getServerDB: vi.fn(() => testDB), +})); + +vi.mock('@/server/services/file', () => ({ + FileService: vi.fn().mockImplementation(() => ({ + getFullFileUrl: vi.fn().mockImplementation((path: string) => (path ? `/files${path}` : null)), + })), +})); + +let mockResponsesCreate: any; +let serverDB: LobeChatDatabase; +let userId: string; + +const createTestContext = () => ({ + jwtPayload: { userId }, + userId, +}); + +const hasMemoryTools = (tools: Array<{ name?: string; function?: { name: string } }>) => + tools?.some((t) => (t.name || t.function?.name)?.includes('lobe-user-memory')); + +const setUserMemorySettings = async (enabled: boolean) => { + // Try update first, then insert if no row exists + const result = await serverDB + .update(userSettings) + .set({ memory: { enabled } }) + .where(eq(userSettings.id, userId)) + .returning(); + + if (result.length === 0) { + await serverDB.insert(userSettings).values({ id: userId, memory: { enabled } }); + } +}; + +beforeEach(async () => { + serverDB = await getTestDB(); + testDB = serverDB; + userId = await createTestUser(serverDB); + mockResponsesCreate = vi.spyOn(OpenAI.Responses.prototype, 'create'); + mockResponsesCreate.mockResolvedValue(createMockResponsesAPIStream('Hello') as any); +}); + +afterEach(async () => { + await cleanupTestUser(serverDB, userId); + vi.clearAllMocks(); + vi.restoreAllMocks(); + inMemoryAgentStateManager.clear(); + inMemoryStreamEventManager.clear(); +}); + +const createTestAgent = async (chatConfig: Record = {}) => { + const [agent] = await serverDB + .insert(agents) + .values({ + chatConfig: chatConfig as any, + model: 'gpt-5-pro', + provider: 'openai', + systemRole: 'test', + title: 'Test', + userId, + }) + .returning(); + return agent; +}; + +describe('execAgent - memory enabled priority', () => { + it('should disable memory tools when agent config sets memory.enabled = false, even if user enables it', async () => { + await setUserMemorySettings(true); + const agent = await createTestAgent({ memory: { enabled: false } }); + + const caller = aiAgentRouter.createCaller(createTestContext()); + const result = await caller.execAgent({ agentId: agent.id, prompt: 'Hello' }); + await waitForOperationComplete(inMemoryAgentStateManager, result.operationId); + + const callArgs = mockResponsesCreate.mock.calls[0][0] as { tools?: any[] }; + expect(hasMemoryTools(callArgs.tools ?? [])).toBe(false); + }); + + it('should enable memory tools when agent config sets memory.enabled = true, even if user disables it', async () => { + await setUserMemorySettings(false); + const agent = await createTestAgent({ memory: { enabled: true } }); + + const caller = aiAgentRouter.createCaller(createTestContext()); + const result = await caller.execAgent({ agentId: agent.id, prompt: 'Hello' }); + await waitForOperationComplete(inMemoryAgentStateManager, result.operationId); + + const callArgs = mockResponsesCreate.mock.calls[0][0] as { tools?: any[] }; + expect(hasMemoryTools(callArgs.tools ?? [])).toBe(true); + }); + + it('should fallback to user setting when agent has no memory config', async () => { + await setUserMemorySettings(false); + const agent = await createTestAgent(); + + const caller = aiAgentRouter.createCaller(createTestContext()); + const result = await caller.execAgent({ agentId: agent.id, prompt: 'Hello' }); + await waitForOperationComplete(inMemoryAgentStateManager, result.operationId); + + const callArgs = mockResponsesCreate.mock.calls[0][0] as { tools?: any[] }; + expect(hasMemoryTools(callArgs.tools ?? [])).toBe(false); + }); + + it('should enable memory by default when neither agent nor user configures it', async () => { + const agent = await createTestAgent(); + + const caller = aiAgentRouter.createCaller(createTestContext()); + const result = await caller.execAgent({ agentId: agent.id, prompt: 'Hello' }); + await waitForOperationComplete(inMemoryAgentStateManager, result.operationId); + + const callArgs = mockResponsesCreate.mock.calls[0][0] as { tools?: any[] }; + expect(hasMemoryTools(callArgs.tools ?? [])).toBe(true); + }); +}); diff --git a/src/server/services/aiAgent/index.ts b/src/server/services/aiAgent/index.ts index eb20e0b68c..95d9aee86c 100644 --- a/src/server/services/aiAgent/index.ts +++ b/src/server/services/aiAgent/index.ts @@ -330,13 +330,17 @@ export class AiAgentService { log('execAgent: got %d klavis manifests', klavisManifests.length); // 8. Fetch user settings (memory config + timezone) - let globalMemoryEnabled = false; + // Agent-level memory config takes priority; fallback to user-level setting + const agentMemoryEnabled = agentConfig.chatConfig?.memory?.enabled; + let globalMemoryEnabled = agentMemoryEnabled ?? false; let userTimezone: string | undefined; try { const userModel = new UserModel(this.db, this.userId); const settings = await userModel.getUserSettings(); const memorySettings = settings?.memory as { enabled?: boolean } | undefined; - globalMemoryEnabled = memorySettings?.enabled !== false; + + globalMemoryEnabled = agentMemoryEnabled ?? memorySettings?.enabled !== false; + const generalSettings = settings?.general as { timezone?: string } | undefined; userTimezone = generalSettings?.timezone; } catch (error) {