mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
17
src/features/ChatInput/ActionBar/Memory/useMemoryEnabled.ts
Normal file
17
src/features/ChatInput/ActionBar/Memory/useMemoryEnabled.ts
Normal 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;
|
||||
};
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
144
src/server/services/aiAgent/__tests__/execAgent.memory.test.ts
Normal file
144
src/server/services/aiAgent/__tests__/execAgent.memory.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user