🐛 fix: respect agent-level memory config priority over user settings (#13018)

* 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) <noreply@anthropic.com>

* 🐛 fix: resolve tsgo type error in memory integration test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-03-16 15:48:14 +08:00
committed by GitHub
parent 1d1e48d1b5
commit 517a67ced7
9 changed files with 195 additions and 16 deletions

View File

@@ -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 <number> --json author --jq '.author.login'` or `git log` commit author. Do NOT hardcode a username.

View File

@@ -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 <number> --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

View File

@@ -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<ToggleOption>(({ 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[] = [
{

View File

@@ -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 <Action disabled icon={Brain} />;

View File

@@ -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;
};

View File

@@ -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', () => ({

View File

@@ -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,
},
}),

View File

@@ -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<string, any> = {}) => {
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);
});
});

View File

@@ -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) {