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
5 changes: 0 additions & 5 deletions packages/agent-sdk/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,11 +709,6 @@ export class Agent {

// Clear messages and generate new session
this.messageManager.clearMessages();
const memoryService =
this.container.get<import("./services/memory.js").MemoryService>(
"MemoryService",
);
memoryService?.clearCache();
await this.taskManager.syncWithSession();

// Run SessionStart hooks (restore context for new session)
Expand Down
24 changes: 18 additions & 6 deletions packages/agent-sdk/src/managers/aiManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,12 +1185,24 @@ export class AIManager {
}

// Recursively call AI service, increment recursion depth, and pass same configuration
await this.sendAIMessage({
recursionDepth: recursionDepth + 1,
model,
allowedRules,
maxTokens,
});
// Yield to the event loop before recursing so macrotasks
// (abort timers, signals) can be processed between turns.
await new Promise((resolve) => setImmediate(resolve));

// Re-check abort status after yielding — the signal may have
// fired during the setImmediate gap.
const isAbortedAfterYield =
abortController.signal.aborted ||
toolAbortController.signal.aborted;

if (!isAbortedAfterYield) {
await this.sendAIMessage({
recursionDepth: recursionDepth + 1,
model,
allowedRules,
maxTokens,
});
}
}
}
}
Expand Down
50 changes: 34 additions & 16 deletions packages/agent-sdk/src/services/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,26 @@ import { getGitCommonDir } from "../utils/gitUtils.js";
import { pathEncoder } from "../utils/pathEncoder.js";

export class MemoryService {
private _cachedProjectMemory: string = "";
private _cachedUserMemory: string = "";
private _cachedProjectMemory: string | null = null;
private _cachedUserMemory: string | null = null;
private _cachedCombinedMemory: string | null = null;
private _cachedAutoMemoryContent: string | null = null;

constructor(private container: Container) {}

public get cachedProjectMemory(): string {
return this._cachedProjectMemory;
return this._cachedProjectMemory ?? "";
}

public get cachedUserMemory(): string {
return this._cachedUserMemory;
return this._cachedUserMemory ?? "";
}

public clearCache(): void {
this._cachedProjectMemory = "";
this._cachedUserMemory = "";
this._cachedProjectMemory = null;
this._cachedUserMemory = null;
this._cachedCombinedMemory = null;
this._cachedAutoMemoryContent = null;
}

/**
Expand Down Expand Up @@ -76,15 +78,20 @@ export class MemoryService {
* Get the first 200 lines of MEMORY.md.
*/
async getAutoMemoryContent(workdir: string): Promise<string> {
if (this._cachedAutoMemoryContent !== null) {
return this._cachedAutoMemoryContent;
}
const memoryDir = this.getAutoMemoryDirectory(workdir);
const memoryFile = path.join(memoryDir, "MEMORY.md");

try {
const content = await fs.readFile(memoryFile, "utf-8");
const lines = content.split("\n").slice(0, 200);
return lines.join("\n");
this._cachedAutoMemoryContent = lines.join("\n");
return this._cachedAutoMemoryContent;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
this._cachedAutoMemoryContent = "";
return "";
}
logger.error("Failed to read auto-memory content:", error);
Expand Down Expand Up @@ -123,6 +130,9 @@ export class MemoryService {
}

async getUserMemoryContent(): Promise<string> {
if (this._cachedUserMemory !== null) {
return this._cachedUserMemory;
}
try {
await this.ensureUserMemoryFile();
const content = await fs.readFile(USER_MEMORY_FILE, "utf-8");
Expand All @@ -145,28 +155,34 @@ export class MemoryService {
contentLength: claudeContent.length,
},
);
return claudeContent;
this._cachedUserMemory = claudeContent;
return this._cachedUserMemory;
} catch {
// CLAUDE.md doesn't exist or can't be read, return the default template
}
}

return content;
this._cachedUserMemory = content;
return this._cachedUserMemory;
} catch (error) {
logger.error("Failed to read user memory:", error);
return "";
}
}

async readMemoryFile(workdir: string): Promise<string> {
if (this._cachedProjectMemory !== null) {
return this._cachedProjectMemory;
}
const memoryFilePath = path.join(workdir, "AGENTS.md");
try {
const content = await fs.readFile(memoryFilePath, "utf-8");
logger.debug("Memory file read successfully via direct file access", {
memoryFilePath,
contentLength: content.length,
});
return content;
this._cachedProjectMemory = content;
return this._cachedProjectMemory;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
// Fallback to CLAUDE.md
Expand All @@ -177,12 +193,14 @@ export class MemoryService {
memoryFilePath: claudeMemoryPath,
contentLength: content.length,
});
return content;
this._cachedProjectMemory = content;
return this._cachedProjectMemory;
} catch (claudeError) {
if ((claudeError as NodeJS.ErrnoException).code === "ENOENT") {
logger.debug("Neither AGENTS.md nor CLAUDE.md found", {
memoryFilePath,
});
this._cachedProjectMemory = "";
return "";
}
logger.error("Failed to read CLAUDE.md fallback", {
Expand All @@ -201,14 +219,14 @@ export class MemoryService {
if (this._cachedCombinedMemory !== null) {
return this._cachedCombinedMemory;
}
this._cachedProjectMemory = await this.readMemoryFile(workdir);
this._cachedUserMemory = await this.getUserMemoryContent();
const projectMemory = await this.readMemoryFile(workdir);
const userMemory = await this.getUserMemoryContent();

let combined = "";
if (this._cachedProjectMemory.trim()) combined += this._cachedProjectMemory;
if (this._cachedUserMemory.trim()) {
if (projectMemory.trim()) combined += projectMemory;
if (userMemory.trim()) {
if (combined) combined += "\n\n";
combined += this._cachedUserMemory;
combined += userMemory;
}
this._cachedCombinedMemory = combined;
return combined;
Expand Down
1 change: 1 addition & 0 deletions packages/agent-sdk/src/utils/containerSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function setupAgentContainer(
logger.error("Failed to sync task list with session:", error);
});
}
memoryService.clearCache();
callbacks.onSessionIdChange?.(sessionId);
},
},
Expand Down
95 changes: 95 additions & 0 deletions packages/agent-sdk/tests/services/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,35 @@ describe("MemoryService", () => {
const result = await memoryService.getAutoMemoryContent(workdir);
expect(result).toBe("");
});

it("should cache content on first call and not read from disk on second call", async () => {
const workdir = "/mock/workdir";
vi.mocked(fsPromises.readFile).mockResolvedValue("cached memory content");

const result1 = await memoryService.getAutoMemoryContent(workdir);
expect(result1).toBe("cached memory content");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(1);

const result2 = await memoryService.getAutoMemoryContent(workdir);
expect(result2).toBe("cached memory content");
// readFile should NOT be called again — cached value returned
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(1);
});

it("should re-read from disk after clearCache() invalidates the cache", async () => {
const workdir = "/mock/workdir";
vi.mocked(fsPromises.readFile).mockResolvedValue("first content");

await memoryService.getAutoMemoryContent(workdir);
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(1);

memoryService.clearCache();

vi.mocked(fsPromises.readFile).mockResolvedValue("second content");
const result = await memoryService.getAutoMemoryContent(workdir);
expect(result).toBe("second content");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(2);
});
});

describe("getUserMemoryContent", () => {
Expand Down Expand Up @@ -172,6 +201,32 @@ describe("MemoryService", () => {

expect(result).toBe(claudeContent);
});

it("should cache content on first call and not read from disk on second call", async () => {
vi.mocked(fsPromises.readFile).mockResolvedValue("cached user memory");

const result1 = await memoryService.getUserMemoryContent();
expect(result1).toBe("cached user memory");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(1);

const result2 = await memoryService.getUserMemoryContent();
expect(result2).toBe("cached user memory");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(1);
});

it("should re-read from disk after clearCache() invalidates the cache", async () => {
vi.mocked(fsPromises.readFile).mockResolvedValue("first user content");

await memoryService.getUserMemoryContent();
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(1);

memoryService.clearCache();

vi.mocked(fsPromises.readFile).mockResolvedValue("second user content");
const result = await memoryService.getUserMemoryContent();
expect(result).toBe("second user content");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(2);
});
});

describe("ensureUserMemoryFile", () => {
Expand Down Expand Up @@ -263,6 +318,34 @@ describe("MemoryService", () => {
expect(result).toBe("");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(2);
});

it("should cache content on first call and not read from disk on second call", async () => {
vi.mocked(fsPromises.readFile).mockResolvedValue(
"# Cached Project Memory\n\nProject content",
);

const result1 = await memoryService.readMemoryFile("/mock/workdir");
expect(result1).toBe("# Cached Project Memory\n\nProject content");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(1);

const result2 = await memoryService.readMemoryFile("/mock/workdir");
expect(result2).toBe("# Cached Project Memory\n\nProject content");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(1);
});

it("should re-read from disk after clearCache() invalidates the cache", async () => {
vi.mocked(fsPromises.readFile).mockResolvedValue("first content");

await memoryService.readMemoryFile("/mock/workdir");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(1);

memoryService.clearCache();

vi.mocked(fsPromises.readFile).mockResolvedValue("second content");
const result = await memoryService.readMemoryFile("/mock/workdir");
expect(result).toBe("second content");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(2);
});
});

describe("getCombinedMemoryContent", () => {
Expand All @@ -279,5 +362,17 @@ describe("MemoryService", () => {
expect(result).toContain("User content");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(2);
});

it("should cache combined content and not read from disk on second call", async () => {
vi.mocked(fsPromises.readFile)
.mockResolvedValueOnce("# Project Memory\n\nProject content")
.mockResolvedValueOnce("# User Memory\n\nUser content");

await memoryService.getCombinedMemoryContent("/mock/workdir");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(2);

await memoryService.getCombinedMemoryContent("/mock/workdir");
expect(vi.mocked(fsPromises.readFile)).toHaveBeenCalledTimes(2);
});
});
});