refactor(memory): replace MemoryManagerAgent with prompt-driven memory editing across four tiers (#25716)

This commit is contained in:
Sandy Tao
2026-04-21 18:21:55 -07:00
committed by GitHub
parent ffb28c772b
commit 6edfba481f
24 changed files with 772 additions and 477 deletions
+79 -8
View File
@@ -3500,7 +3500,7 @@ describe('Config JIT Initialization', () => {
expect(config.getUserMemory()).toBe('Initial Memory');
});
describe('isMemoryManagerEnabled', () => {
describe('isMemoryV2Enabled', () => {
it('should default to false', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
@@ -3511,21 +3511,92 @@ describe('Config JIT Initialization', () => {
};
config = new Config(params);
expect(config.isMemoryManagerEnabled()).toBe(false);
expect(config.isMemoryV2Enabled()).toBe(false);
});
it('should return true when experimentalMemoryManager is true', () => {
it('should return true when experimentalMemoryV2 is true', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalMemoryManager: true,
experimentalMemoryV2: true,
};
config = new Config(params);
expect(config.isMemoryManagerEnabled()).toBe(true);
expect(config.isMemoryV2Enabled()).toBe(true);
});
it('should NOT add the global ~/.gemini directory to the workspace when enabled', async () => {
// The prompt-driven memoryV2 mode does not broaden the workspace
// to include the global ~/.gemini/ directory. Cross-project personal
// preferences are routed to ~/.gemini/GEMINI.md via the surgical
// isPathAllowed allowlist instead — see the next two tests.
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalMemoryV2: true,
};
config = new Config(params);
await config.initialize();
const directories = config.getWorkspaceContext().getDirectories();
expect(directories).not.toContain(Storage.getGlobalGeminiDir());
});
it('should allow isPathAllowed to write the global ~/.gemini/GEMINI.md file', async () => {
// Surgical allowlist: when memoryV2 is on, the prompt routes
// cross-project personal preferences to ~/.gemini/GEMINI.md, so the
// agent must be able to edit that exact file via edit/write_file.
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalMemoryV2: true,
};
config = new Config(params);
await config.initialize();
const globalGeminiMdPath = path.join(
Storage.getGlobalGeminiDir(),
'GEMINI.md',
);
expect(config.isPathAllowed(globalGeminiMdPath)).toBe(true);
});
it('should NOT allow isPathAllowed to write other files under ~/.gemini/ (least privilege)', async () => {
// The allowlist is surgical: only ~/.gemini/GEMINI.md is reachable.
// settings.json, keybindings.json, credentials, etc. remain disallowed.
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalMemoryV2: true,
};
config = new Config(params);
await config.initialize();
const globalDir = Storage.getGlobalGeminiDir();
expect(config.isPathAllowed(path.join(globalDir, 'settings.json'))).toBe(
false,
);
expect(
config.isPathAllowed(path.join(globalDir, 'keybindings.json')),
).toBe(false);
expect(
config.isPathAllowed(path.join(globalDir, 'oauth_creds.json')),
).toBe(false);
});
});
@@ -3557,18 +3628,18 @@ describe('Config JIT Initialization', () => {
expect(config.isAutoMemoryEnabled()).toBe(true);
});
it('should be independent of experimentalMemoryManager', () => {
it('should be independent of experimentalMemoryV2', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalMemoryManager: true,
experimentalMemoryV2: true,
};
config = new Config(params);
expect(config.isMemoryManagerEnabled()).toBe(true);
expect(config.isMemoryV2Enabled()).toBe(true);
expect(config.isAutoMemoryEnabled()).toBe(false);
});
});
+34 -10
View File
@@ -41,7 +41,11 @@ import { EditTool } from '../tools/edit.js';
import { ShellTool } from '../tools/shell.js';
import { WriteFileTool } from '../tools/write-file.js';
import { WebFetchTool } from '../tools/web-fetch.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import {
MemoryTool,
setGeminiMdFilename,
getCurrentGeminiMdFilename,
} from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js';
import { AskUserTool } from '../tools/ask-user.js';
import { UpdateTopicTool } from '../tools/topicTool.js';
@@ -705,7 +709,7 @@ export interface ConfigParameters {
adminSkillsEnabled?: boolean;
experimentalJitContext?: boolean;
autoDistillation?: boolean;
experimentalMemoryManager?: boolean;
experimentalMemoryV2?: boolean;
experimentalAutoMemory?: boolean;
experimentalContextManagementConfig?: string;
experimentalAgentHistoryTruncation?: boolean;
@@ -950,7 +954,7 @@ export class Config implements McpContext, AgentLoopContext {
private disabledSkills: string[];
private readonly adminSkillsEnabled: boolean;
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryManager: boolean;
private readonly experimentalMemoryV2: boolean;
private readonly experimentalAutoMemory: boolean;
private readonly experimentalContextManagementConfig?: string;
private readonly memoryBoundaryMarkers: readonly string[];
@@ -1167,8 +1171,8 @@ export class Config implements McpContext, AgentLoopContext {
modelConfigServiceConfig ?? DEFAULT_MODEL_CONFIGS,
);
this.experimentalJitContext = params.experimentalJitContext ?? false;
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
this.experimentalJitContext = params.experimentalJitContext ?? true;
this.experimentalMemoryV2 = params.experimentalMemoryV2 ?? false;
this.experimentalAutoMemory = params.experimentalAutoMemory ?? false;
this.experimentalContextManagementConfig =
params.experimentalContextManagementConfig;
@@ -2502,8 +2506,8 @@ export class Config implements McpContext, AgentLoopContext {
return this.memoryBoundaryMarkers;
}
isMemoryManagerEnabled(): boolean {
return this.experimentalMemoryManager;
isMemoryV2Enabled(): boolean {
return this.experimentalMemoryV2;
}
isAutoMemoryEnabled(): boolean {
@@ -3031,7 +3035,10 @@ export class Config implements McpContext, AgentLoopContext {
/**
* Checks if a given absolute path is allowed for file system operations.
* A path is allowed if it's within the workspace context or the project's temporary directory.
* A path is allowed if it's within the workspace context, the project's
* temporary directory, or is exactly the global personal `~/.gemini/GEMINI.md`
* file (the latter is the only file under `~/.gemini/` that is reachable
* settings, credentials, keybindings, etc. remain disallowed).
*
* @param absolutePath The absolute path to check.
* @returns true if the path is allowed, false otherwise.
@@ -3046,8 +3053,25 @@ export class Config implements McpContext, AgentLoopContext {
const projectTempDir = this.storage.getProjectTempDir();
const resolvedTempDir = resolveToRealPath(projectTempDir);
if (isSubpath(resolvedTempDir, resolvedPath)) {
return true;
}
return isSubpath(resolvedTempDir, resolvedPath);
// Surgical allowlist: the global personal GEMINI.md file (and ONLY that
// file) is reachable so the prompt-driven memory flow can persist
// cross-project personal preferences. This deliberately does NOT
// allowlist the rest of `~/.gemini/`.
const globalMemoryFilePath = path.join(
Storage.getGlobalGeminiDir(),
getCurrentGeminiMdFilename(),
);
const resolvedGlobalMemoryFilePath =
resolveToRealPath(globalMemoryFilePath);
if (resolvedPath === resolvedGlobalMemoryFilePath) {
return true;
}
return false;
}
/**
@@ -3681,7 +3705,7 @@ export class Config implements McpContext, AgentLoopContext {
new ReadBackgroundOutputTool(this, this.messageBus),
),
);
if (!this.isMemoryManagerEnabled()) {
if (!this.isMemoryV2Enabled()) {
maybeRegister(MemoryTool, () =>
registry.registerTool(new MemoryTool(this.messageBus, this.storage)),
);
+1 -1
View File
@@ -24,7 +24,7 @@ export function flattenMemory(memory?: string | HierarchicalMemory): string {
}
if (memory.userProjectMemory?.trim()) {
sections.push({
name: 'User Project Memory',
name: 'Private Project Memory',
content: memory.userProjectMemory.trim(),
});
}
@@ -45,19 +45,28 @@ describe('Config Path Validation', () => {
});
});
it('should allow access to ~/.gemini if it is added to the workspace', () => {
const geminiMdPath = path.join(globalGeminiDir, 'GEMINI.md');
it('should allow access to a file under ~/.gemini once that directory is added to the workspace', () => {
// Use settings.json rather than GEMINI.md as the example: the latter is
// now reachable via a surgical isPathAllowed allowlist regardless of
// workspace membership (covered by dedicated tests in config.test.ts), so
// it can no longer demonstrate the workspace-addition semantic on its
// own. settings.json is NOT on the allowlist, so it preserves the
// original "denied -> add to workspace -> allowed" flow this test was
// written to verify, and additionally double-asserts the least-privilege
// guarantee that the allowlist does not leak access to other files
// under ~/.gemini/.
const settingsPath = path.join(globalGeminiDir, 'settings.json');
// Before adding, it should be denied
expect(config.isPathAllowed(geminiMdPath)).toBe(false);
expect(config.isPathAllowed(settingsPath)).toBe(false);
// Add to workspace
config.getWorkspaceContext().addDirectory(globalGeminiDir);
// Now it should be allowed
expect(config.isPathAllowed(geminiMdPath)).toBe(true);
expect(config.validatePathAccess(geminiMdPath, 'read')).toBeNull();
expect(config.validatePathAccess(geminiMdPath, 'write')).toBeNull();
expect(config.isPathAllowed(settingsPath)).toBe(true);
expect(config.validatePathAccess(settingsPath, 'read')).toBeNull();
expect(config.validatePathAccess(settingsPath, 'write')).toBeNull();
});
it('should still allow project workspace paths', () => {