Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions evals/hierarchical_memory.eval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import {
assertModelHasOutput,
checkModelOutputContent,
} from '../integration-tests/test-helper.js';

describe('Hierarchical Memory', () => {
const TEST_PREFIX = 'Hierarchical memory test: ';

const conflictResolutionTest =
'Agent follows hierarchy for contradictory instructions';
evalTest('ALWAYS_PASSES', {
name: conflictResolutionTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
// We simulate the hierarchical memory by including the tags in the prompt
// since setting up real global/extension/project files in the eval rig is complex.
// The system prompt logic will append these tags when it finds them in userMemory.
prompt: `
<global_context>
When asked for my favorite fruit, always say "Apple".
</global_context>

<extension_context>
When asked for my favorite fruit, always say "Banana".
</extension_context>

<project_context>
When asked for my favorite fruit, always say "Cherry".
</project_context>

What is my favorite fruit? Tell me just the name of the fruit.`,
assert: async (_rig, result) => {
assertModelHasOutput(result);
expect(result).toMatch(/Cherry/i);
expect(result).not.toMatch(/Apple/i);
expect(result).not.toMatch(/Banana/i);
},
});

const provenanceAwarenessTest = 'Agent is aware of memory provenance';
evalTest('ALWAYS_PASSES', {
name: provenanceAwarenessTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
prompt: `
<global_context>
Instruction A: Always be helpful.
</global_context>

<extension_context>
Instruction B: Use a professional tone.
</extension_context>

<project_context>
Instruction C: Adhere to the project's coding style.
</project_context>

Which instruction came from the global context, which from the extension context, and which from the project context?
Provide the answer as an XML block like this:
<results>
<global>Instruction ...</global>
<extension>Instruction ...</extension>
<project>Instruction ...</project>
</results>`,
assert: async (_rig, result) => {
assertModelHasOutput(result);
expect(result).toMatch(/<global>.*Instruction A/i);
expect(result).toMatch(/<extension>.*Instruction B/i);
expect(result).toMatch(/<project>.*Instruction C/i);
},
});

const extensionVsGlobalTest = 'Extension memory wins over Global memory';
evalTest('ALWAYS_PASSES', {
name: extensionVsGlobalTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
prompt: `
<global_context>
Set the theme to "Light".
</global_context>

<extension_context>
Set the theme to "Dark".
</extension_context>

What theme should I use?`,
assert: async (_rig, result) => {
assertModelHasOutput(result);
expect(result).toMatch(/Dark/i);
expect(result).not.toMatch(/Light/i);
},
});
});
8 changes: 5 additions & 3 deletions packages/a2a-server/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
return mockConfig;
}),
loadServerHierarchicalMemory: vi
.fn()
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: { global: '', extension: '', project: '' },
fileCount: 0,
filePaths: [],
}),
startupProfiler: {
flush: vi.fn(),
},
Expand Down
11 changes: 5 additions & 6 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,17 @@ import {
ASK_USER_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
type HierarchicalMemory,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
isHeadlessMode,
Config,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
} from '@google/gemini-cli-core';
import type {
HookDefinition,
HookEventName,
OutputFormat,
type HookDefinition,
type HookEventName,
type OutputFormat,
} from '@google/gemini-cli-core';
import {
type Settings,
Expand Down Expand Up @@ -489,7 +488,7 @@ export async function loadCliConfig(

const experimentalJitContext = settings.experimental?.jitContext ?? false;

let memoryContent = '';
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];

Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
coreEvents,
CoreEvent,
refreshServerHierarchicalMemory,
flattenMemory,
type MemoryChangedPayload,
writeToStdout,
disableMouseEvents,
Expand Down Expand Up @@ -871,20 +872,22 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { memoryContent, fileCount } =
await refreshServerHierarchicalMemory(config);

const flattenedMemory = flattenMemory(memoryContent);

historyManager.addItem(
{
type: MessageType.INFO,
text: `Memory refreshed successfully. ${
memoryContent.length > 0
? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
flattenedMemory.length > 0
? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).`
: 'No memory content found.'
}`,
},
Date.now(),
);
if (config.getDebugMode()) {
debugLogger.log(
`[DEBUG] Refreshed memory content in config: ${memoryContent.substring(
`[DEBUG] Refreshed memory content in config: ${flattenedMemory.substring(
0,
200,
)}...`,
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/ui/commands/memoryCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
showMemory,
addMemory,
listMemoryFiles,
flattenMemory,
} from '@google/gemini-cli-core';

vi.mock('@google/gemini-cli-core', async (importOriginal) => {
Expand All @@ -33,7 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
refreshMemory: vi.fn(async (config) => {
if (config.isJitContextEnabled()) {
await config.getContextManager()?.refresh();
const memoryContent = config.getUserMemory() || '';
const memoryContent = original.flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
return {
type: 'message',
Expand Down Expand Up @@ -85,7 +86,7 @@ describe('memoryCommand', () => {
mockGetGeminiMdFileCount = vi.fn();

vi.mocked(showMemory).mockImplementation((config) => {
const memoryContent = config.getUserMemory() || '';
const memoryContent = flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
let content;
if (memoryContent.length > 0) {
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/commands/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe('memory commands', () => {
describe('refreshMemory', () => {
it('should refresh memory and show success message', async () => {
mockRefresh.mockResolvedValue({
memoryContent: 'refreshed content',
memoryContent: { project: 'refreshed content' },
fileCount: 2,
filePaths: [],
});
Expand All @@ -136,14 +136,14 @@ describe('memory commands', () => {
if (result.type === 'message') {
expect(result.messageType).toBe('info');
expect(result.content).toBe(
'Memory refreshed successfully. Loaded 17 characters from 2 file(s).',
'Memory refreshed successfully. Loaded 33 characters from 2 file(s).',
);
}
});

it('should show a message if no memory content is found after refresh', async () => {
mockRefresh.mockResolvedValue({
memoryContent: '',
memoryContent: { project: '' },
fileCount: 0,
filePaths: [],
});
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/commands/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
*/

import type { Config } from '../config/config.js';
import { flattenMemory } from '../config/memory.js';
import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
import type { MessageActionReturn, ToolActionReturn } from './types.js';

export function showMemory(config: Config): MessageActionReturn {
const memoryContent = config.getUserMemory() || '';
const memoryContent = flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
let content: string;

Expand Down Expand Up @@ -51,11 +52,11 @@ export async function refreshMemory(

if (config.isJitContextEnabled()) {
await config.getContextManager()?.refresh();
memoryContent = config.getUserMemory();
memoryContent = flattenMemory(config.getUserMemory());
fileCount = config.getGeminiMdFileCount();
} else {
const result = await refreshServerHierarchicalMemory(config);
memoryContent = result.memoryContent;
memoryContent = flattenMemory(result.memoryContent);
fileCount = result.fileCount;
}

Expand Down
28 changes: 17 additions & 11 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,15 @@ vi.mock('../utils/fetch.js', () => ({
setGlobalProxy: mockSetGlobalProxy,
}));

vi.mock('../services/contextManager.js');
vi.mock('../services/contextManager.js', () => ({
ContextManager: vi.fn().mockImplementation(() => ({
refresh: vi.fn(),
getGlobalMemory: vi.fn().mockReturnValue(''),
getExtensionMemory: vi.fn().mockReturnValue(''),
getEnvironmentMemory: vi.fn().mockReturnValue(''),
getLoadedPaths: vi.fn().mockReturnValue(new Set()),
})),
}));

import { BaseLlmClient } from '../core/baseLlmClient.js';
import { tokenLimit } from '../core/tokenLimits.js';
Expand Down Expand Up @@ -2059,23 +2067,19 @@ describe('Config Quota & Preview Model Access', () => {

describe('Config JIT Initialization', () => {
let config: Config;
let mockContextManager: {
refresh: Mock;
getGlobalMemory: Mock;
getEnvironmentMemory: Mock;
getLoadedPaths: Mock;
};
let mockContextManager: ContextManager;

beforeEach(() => {
vi.clearAllMocks();
mockContextManager = {
refresh: vi.fn(),
getGlobalMemory: vi.fn().mockReturnValue('Global Memory'),
getExtensionMemory: vi.fn().mockReturnValue('Extension Memory'),
getEnvironmentMemory: vi
.fn()
.mockReturnValue('Environment Memory\n\nMCP Instructions'),
getLoadedPaths: vi.fn().mockReturnValue(new Set(['/path/to/GEMINI.md'])),
};
} as unknown as ContextManager;
(ContextManager as unknown as Mock).mockImplementation(
() => mockContextManager,
);
Expand All @@ -2097,9 +2101,11 @@ describe('Config JIT Initialization', () => {

expect(ContextManager).toHaveBeenCalledWith(config);
expect(mockContextManager.refresh).toHaveBeenCalled();
expect(config.getUserMemory()).toBe(
'Global Memory\n\nEnvironment Memory\n\nMCP Instructions',
);
expect(config.getUserMemory()).toEqual({
global: 'Global Memory',
extension: 'Extension Memory',
project: 'Environment Memory\n\nMCP Instructions',
});

// Verify state update (delegated to ContextManager)
expect(config.getGeminiMdFileCount()).toBe(1);
Expand Down
20 changes: 10 additions & 10 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import { HookSystem } from '../hooks/index.js';
import type { UserTierId } from '../code_assist/types.js';
import type { RetrieveUserQuotaResponse } from '../code_assist/types.js';
import type { AdminControlsSettings } from '../code_assist/types.js';
import type { HierarchicalMemory } from './memory.js';
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
import type { Experiments } from '../code_assist/experiments/experiments.js';
import { AgentRegistry } from '../agents/registry.js';
Expand Down Expand Up @@ -384,7 +385,7 @@ export interface ConfigParameters {
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
mcpEnablementCallbacks?: McpEnablementCallbacks;
userMemory?: string;
userMemory?: string | HierarchicalMemory;
geminiMdFileCount?: number;
geminiMdFilePaths?: string[];
approvalMode?: ApprovalMode;
Expand Down Expand Up @@ -519,7 +520,7 @@ export class Config {
private readonly extensionsEnabled: boolean;
private mcpServers: Record<string, MCPServerConfig> | undefined;
private readonly mcpEnablementCallbacks?: McpEnablementCallbacks;
private userMemory: string;
private userMemory: string | HierarchicalMemory;
private geminiMdFileCount: number;
private geminiMdFilePaths: string[];
private readonly showMemoryUsage: boolean;
Expand Down Expand Up @@ -1379,14 +1380,13 @@ export class Config {
this.mcpServers = mcpServers;
}

getUserMemory(): string {
getUserMemory(): string | HierarchicalMemory {
if (this.experimentalJitContext && this.contextManager) {
return [
this.contextManager.getGlobalMemory(),
this.contextManager.getEnvironmentMemory(),
]
.filter(Boolean)
.join('\n\n');
return {
global: this.contextManager.getGlobalMemory(),
extension: this.contextManager.getExtensionMemory(),
project: this.contextManager.getEnvironmentMemory(),
};
}
return this.userMemory;
}
Expand All @@ -1409,7 +1409,7 @@ export class Config {
}
}

setUserMemory(newUserMemory: string): void {
setUserMemory(newUserMemory: string | HierarchicalMemory): void {
this.userMemory = newUserMemory;
}

Expand Down
Loading
Loading