From faa66aa074d29c5eef3fbcf37faf40e4bd10ba0b Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 27 Jul 2026 06:22:07 -0400 Subject: [PATCH 1/2] refactor(space-agents): extract shared buildMemorySectionLines helper Factor the `## Core Memories` + `## Relevant Memories` rendering out of `buildCustomAgentTaskMessage` into an exported `buildMemorySectionLines` helper so worker kickoffs and (upcoming) long-horizon wakes reuse the same formatting and char budgets (core ~2000 chars, each relevant ~500). No behavior change for worker kickoff; existing memory tests stay green. --- .../src/lib/space/agents/custom-agent.ts | 57 +++++++++++------- .../5-space/agent/custom-agent-memory.test.ts | 60 ++++++++++++++++++- 2 files changed, 95 insertions(+), 22 deletions(-) diff --git a/packages/daemon/src/lib/space/agents/custom-agent.ts b/packages/daemon/src/lib/space/agents/custom-agent.ts index 9074f77b1..1fd3034e4 100644 --- a/packages/daemon/src/lib/space/agents/custom-agent.ts +++ b/packages/daemon/src/lib/space/agents/custom-agent.ts @@ -329,27 +329,9 @@ export function buildCustomAgentTaskMessage(config: CustomAgentConfig): string { sections.push(...previousWorkLines); } - // 7. Core memories are space-scoped and selected by background consolidation. - const coreMemoryLines = buildCoreMemoryLines(coreMemories); - if (coreMemoryLines.length > 0) { - sections.push(''); - sections.push('## Core Memories'); - sections.push(''); - sections.push(...coreMemoryLines); - } - - // 8. Relevant persistent memories. - if (relevantMemories && relevantMemories.length > 0) { - sections.push(''); - sections.push('## Relevant Memories'); - sections.push(''); - for (const result of relevantMemories) { - const tags = result.memory.tags.length > 0 ? ` [${result.memory.tags.join(', ')}]` : ''; - sections.push( - `- ${result.memory.key}${tags}: ${truncateMemoryPromptContent(result.memory.content)}` - ); - } - } + // 7-8. Core + relevant persistent memories (shared builder keeps formatting + // and char budgets identical across worker kickoffs and long-horizon wakes). + sections.push(...buildMemorySectionLines({ coreMemories, relevantMemories })); // 9. Project context from the Space. if (space.backgroundContext) { @@ -430,6 +412,39 @@ function truncateBulletLine(line: string, limit: number): string { return `${line.slice(0, Math.max(0, limit - 1)).trimEnd()}…`; } +/** + * Build the `## Core Memories` and `## Relevant Memories` section lines shared + * by worker kickoff messages and long-horizon agent wake messages. + * + * Returns `[]` when there is nothing to render, so callers can cleanly omit the + * block. The leading blank line separates this block from the preceding section + * when the lines are pushed into a `sections[]` array; a standalone consumer can + * `join('\n').trim()` to drop it. Char budgets (core ~2000 chars total, each + * relevant memory ~500 chars) are enforced by the per-line builders so both + * consumers stay consistent. + */ +export function buildMemorySectionLines(options: { + coreMemories?: AgentMemoryCoreEntry[]; + relevantMemories?: AgentMemorySearchResult[]; +}): string[] { + const lines: string[] = []; + const coreMemoryLines = buildCoreMemoryLines(options.coreMemories); + if (coreMemoryLines.length > 0) { + lines.push('', '## Core Memories', ''); + lines.push(...coreMemoryLines); + } + if (options.relevantMemories && options.relevantMemories.length > 0) { + lines.push('', '## Relevant Memories', ''); + for (const result of options.relevantMemories) { + const tags = result.memory.tags.length > 0 ? ` [${result.memory.tags.join(', ')}]` : ''; + lines.push( + `- ${result.memory.key}${tags}: ${truncateMemoryPromptContent(result.memory.content)}` + ); + } + } + return lines; +} + function buildCoreMemoryLines(coreMemories: AgentMemoryCoreEntry[] | undefined): string[] { if (!coreMemories || coreMemories.length === 0) return []; const lines: string[] = []; diff --git a/packages/daemon/tests/unit/5-space/agent/custom-agent-memory.test.ts b/packages/daemon/tests/unit/5-space/agent/custom-agent-memory.test.ts index b8f0ffec5..5c8daecd9 100644 --- a/packages/daemon/tests/unit/5-space/agent/custom-agent-memory.test.ts +++ b/packages/daemon/tests/unit/5-space/agent/custom-agent-memory.test.ts @@ -1,5 +1,8 @@ import { describe, test, expect } from 'bun:test'; -import { buildCustomAgentTaskMessage } from '../../../../src/lib/space/agents/custom-agent.ts'; +import { + buildCustomAgentTaskMessage, + buildMemorySectionLines, +} from '../../../../src/lib/space/agents/custom-agent.ts'; import type { Space, SpaceWorkerAgent, SpaceTask } from '@hyperneo/shared'; import type { AgentMemorySearchResult } from '../../../../src/storage/repositories/agent-memory-repository.ts'; @@ -185,3 +188,58 @@ describe('buildCustomAgentTaskMessage memory injection', () => { expect(message).not.toContain('x'.repeat(501)); }); }); + +describe('buildMemorySectionLines (shared by worker kickoff and LH wake)', () => { + test('returns an empty array when no memories are provided', () => { + expect(buildMemorySectionLines({})).toEqual([]); + expect(buildMemorySectionLines({ coreMemories: [], relevantMemories: [] })).toEqual([]); + }); + + test('renders core before relevant with a leading separator line', () => { + const lines = buildMemorySectionLines({ + coreMemories: [{ ...memories[0].memory, key: 'core.conventions', score: 10 }], + relevantMemories: memories, + }); + // Leading blank line separates this block from a preceding section. + expect(lines[0]).toBe(''); + const block = lines.join('\n'); + expect(block).toContain('## Core Memories'); + expect(block).toContain('## Relevant Memories'); + expect(block.indexOf('## Core Memories')).toBeLessThan(block.indexOf('## Relevant Memories')); + // join+trim drops the leading separator — what the LH wake prepends. + expect(block.trim().startsWith('## Core Memories')).toBe(true); + }); + + test('enforces the core memory char budget across the whole block', () => { + const lines = buildMemorySectionLines({ + coreMemories: [ + { + ...memories[0].memory, + key: 'core.large', + content: 'x'.repeat(3_000), + score: 10, + }, + ], + }); + const block = lines.join('\n'); + + expect(block.length).toBeLessThanOrEqual(2_050); + expect(block).toContain('…'); + expect(block).not.toContain('x'.repeat(2_001)); + }); + + test('truncates each relevant memory content to the per-entry budget', () => { + const lines = buildMemorySectionLines({ + relevantMemories: [ + { + rank: -1, + memory: { ...memories[0].memory, content: 'x'.repeat(600) }, + }, + ], + }); + const block = lines.join('\n'); + + expect(block).toContain(`${'x'.repeat(500)}…`); + expect(block).not.toContain('x'.repeat(501)); + }); +}); From d2c62dfb6a4f2f4803c503beeb20e2cd29bc31dc Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 27 Jul 2026 06:23:10 -0400 Subject: [PATCH 2/2] feat(space-runtime): inject core + relevant memories into LH agent wakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-horizon agents started cold on every wake unless they manually called memory_search, breaking continuity across their lifetime. Mirror the worker kickoff injection: prepend a `## Core Memories` (listCoreMemories, top-10) + `## Relevant Memories` (search by wake message text, top-5) block to every injected wake message via the shared `buildMemorySectionLines` helper. Centralized in `injectLongTermAgentMessage` so all wake paths (external events, peer messages, inbox flush) carry memory context. The wake message text is the search query, so relevant — not just core — memories surface. When no memories are stored the wake message is injected unchanged. Char budgets stay consistent with workers via the shared helper. --- .../space/runtime/space-runtime-service.ts | 45 ++++- .../runtime/space-runtime-service.test.ts | 179 ++++++++++++++++++ 2 files changed, 218 insertions(+), 6 deletions(-) diff --git a/packages/daemon/src/lib/space/runtime/space-runtime-service.ts b/packages/daemon/src/lib/space/runtime/space-runtime-service.ts index 978079583..7bc145278 100644 --- a/packages/daemon/src/lib/space/runtime/space-runtime-service.ts +++ b/packages/daemon/src/lib/space/runtime/space-runtime-service.ts @@ -57,7 +57,7 @@ import { SpaceTaskManager } from '../managers/space-task-manager'; import { createSpaceAgentMcpServer } from '../tools/space-agent-tools'; import type { ReplyRoutingRegistry } from './reply-routing-registry'; import { buildSpaceChatSystemPrompt } from '../agents/space-chat-agent'; -import { resolveCustomAgentPrompt } from '../agents/custom-agent'; +import { resolveCustomAgentPrompt, buildMemorySectionLines } from '../agents/custom-agent'; import { Logger } from '../../logger'; import { createDbQueryMcpServer, type DbQueryMcpServer } from '../../db-query/tools'; import { createAgentMemoryMcpServer } from '../tools/agent-memory-tools'; @@ -375,7 +375,12 @@ export class SpaceRuntimeService { } const session = await this.ensureLongHorizonAgentSession(args.spaceId, args.agentId); if (session) { - await this.injectLongTermAgentMessage(session, args.message, args.idempotencyKey); + await this.injectLongTermAgentMessage( + args.spaceId, + session, + args.message, + args.idempotencyKey + ); return { delivered: true }; } return { delivered: false }; @@ -387,7 +392,7 @@ export class SpaceRuntimeService { ): Promise { const session = await this.ensureLongTermAgentSession(actor); if (!session) return null; - await this.injectLongTermAgentMessage(session, message.body); + await this.injectLongTermAgentMessage(actor.spaceId, session, message.body); return session.getSessionData().id; } @@ -469,7 +474,7 @@ export class SpaceRuntimeService { : pending; for (const row of ordered) { try { - await this.injectLongTermAgentMessage(session, row.message, row.id); + await this.injectLongTermAgentMessage(actor.spaceId, session, row.message, row.id); inboxRepo.markDelivered(row.id, session.getSessionData().id); } catch (err) { inboxRepo.markAttemptFailed(row.id, err instanceof Error ? err.message : String(err)); @@ -478,6 +483,7 @@ export class SpaceRuntimeService { } private async injectLongTermAgentMessage( + spaceId: string, session: { getSessionData(): Session; ensureQueryStarted(): Promise; @@ -488,6 +494,8 @@ export class SpaceRuntimeService { ): Promise { const id = messageId ?? generateRuntimeMessageId(); const sessionId = session.getSessionData().id; + const memoryBlock = await this.buildLongTermAgentMemoryBlock(spaceId, message); + const fullMessage = memoryBlock ? `${memoryBlock}\n\n${message}` : message; const sdkUserMessage: SDKUserMessage & { isSynthetic: boolean } = { type: 'user' as const, uuid: id as UUID, @@ -496,15 +504,40 @@ export class SpaceRuntimeService { isSynthetic: true, message: { role: 'user' as const, - content: [{ type: 'text' as const, text: message }], + content: [{ type: 'text' as const, text: fullMessage }], }, }; await session.ensureQueryStarted(); this.config.reactiveDb?.db.saveUserMessage(sessionId, sdkUserMessage, 'enqueued'); - await session.messageQueue.enqueueWithId(id, message); + await session.messageQueue.enqueueWithId(id, fullMessage); return id; } + /** + * Build the space-scoped memories block prepended to a long-horizon agent's + * wake message, mirroring the worker kickoff injection. Core memories come + * from background consolidation; relevant memories are searched using the + * wake message text (external-event summary, reminder body, etc.) so the + * surfaced context tracks what woke the agent. Returns `''` when no memories + * are available (no repo, or nothing stored) so the wake message is injected + * unchanged. + */ + private async buildLongTermAgentMemoryBlock( + spaceId: string, + wakeContext: string + ): Promise { + const memoryRepo = this.config.memoryRepo; + if (!memoryRepo) return ''; + const coreMemories = memoryRepo.listCoreMemories(spaceId, 10); + const relevantMemories = await memoryRepo.search(spaceId, wakeContext, 5); + const lines = buildMemorySectionLines({ coreMemories, relevantMemories }); + if (lines.length === 0) return ''; + // The shared builder emits a leading blank-line separator meant for joining + // into a `sections[]` array. Here the block is prepended, so drop it — the + // blank line is reintroduced by the `${memoryBlock}\n\n${message}` join. + return lines.join('\n').trim(); + } + private buildLongHorizonAgentSessionConfig( space: Space, agent: SpaceLongHorizonAgent diff --git a/packages/daemon/tests/unit/5-space/runtime/space-runtime-service.test.ts b/packages/daemon/tests/unit/5-space/runtime/space-runtime-service.test.ts index 7ec34373a..a45424ee9 100644 --- a/packages/daemon/tests/unit/5-space/runtime/space-runtime-service.test.ts +++ b/packages/daemon/tests/unit/5-space/runtime/space-runtime-service.test.ts @@ -984,6 +984,185 @@ describe('SpaceRuntimeService', () => { expect(createSessionArg.config.model).toBeUndefined(); }); + test('prepends core and relevant memories to a long-horizon agent wake message', async () => { + const sessionId = longTermAgentSessionId(mockSpace.id, 'lh-agent-1'); + const createdSession = { + ...makeSession(), + getSessionData: mock(() => ({ id: sessionId, metadata: {}, config: {} })), + ensureQueryStarted: mock(async () => {}), + messageQueue: { enqueueWithId: mock(async () => {}) }, + } as unknown as AgentSession; + const sessionManager = makeSessionManager(null); + ( + sessionManager.createSession as Mock + ).mockImplementation(async () => sessionId); + (sessionManager.getSessionAsync as Mock) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(createdSession); + const longHorizonAgentRepo = { + getById: mock(() => ({ + id: 'lh-agent-1', + spaceId: mockSpace.id, + handle: 'watcher', + displayName: 'Watcher', + templateKey: null, + status: 'active', + sessionId: null, + instructions: 'Observe events.', + autonomyLevel: null, + model: null, + thinkingLevel: null, + provider: null, + settingSources: null, + toolPermissions: {}, + createdAt: NOW, + updatedAt: NOW, + })), + update: mock(() => {}), + } as unknown as SpaceRuntimeServiceConfig['longHorizonAgentRepo']; + const memoryRepo = { + listCoreMemories: mock(() => [ + { + key: 'space.conventions', + spaceId: mockSpace.id, + content: 'Prefer zod schemas for validation.', + tags: ['conventions'], + createdBySession: null, + createdAt: NOW, + updatedAt: NOW, + accessCount: 5, + lastAccessedAt: NOW, + score: 10, + }, + ]), + search: mock(async () => [ + { + rank: 0.9, + memory: { + key: 'reviews.thread-resolution', + spaceId: mockSpace.id, + content: 'Resolve review threads after replying.', + tags: ['reviews'], + createdBySession: null, + createdAt: NOW, + updatedAt: NOW, + accessCount: 1, + lastAccessedAt: NOW, + }, + }, + ]), + } as unknown as SpaceRuntimeServiceConfig['memoryRepo']; + const svc = new SpaceRuntimeService({ + ...buildConfigWithSession(sessionManager, createMockSpaceManager(mockSpace)), + longHorizonAgentRepo, + memoryRepo, + }); + + const result = await ( + svc as unknown as { + deliverLongHorizonExternalEvent(args: { + spaceId: string; + agentId: string; + message: string; + idempotencyKey: string; + }): Promise<{ delivered: boolean }>; + } + ).deliverLongHorizonExternalEvent({ + spaceId: mockSpace.id, + agentId: 'lh-agent-1', + message: 'event payload', + idempotencyKey: 'delivery-1', + }); + + expect(result).toEqual({ delivered: true }); + // Mirrors the worker kickoff: 10 core memories, top-5 relevant. + expect(memoryRepo.listCoreMemories).toHaveBeenCalledWith(mockSpace.id, 10); + expect(memoryRepo.search).toHaveBeenCalledWith(mockSpace.id, 'event payload', 5); + const injected = (createdSession.messageQueue.enqueueWithId as Mock).mock + .calls[0]![1] as string; + // Memories are prepended; the wake message trails the block. + expect(injected.startsWith('## Core Memories')).toBe(true); + expect(injected).toContain( + '- space.conventions [conventions]: Prefer zod schemas for validation.' + ); + expect(injected).toContain('## Relevant Memories'); + expect(injected.indexOf('## Core Memories')).toBeLessThan( + injected.indexOf('## Relevant Memories') + ); + expect(injected.indexOf('## Relevant Memories')).toBeLessThan( + injected.indexOf('event payload') + ); + expect(injected.endsWith('event payload')).toBe(true); + }); + + test('injects the wake message unchanged when the space has no memories', async () => { + const sessionId = longTermAgentSessionId(mockSpace.id, 'lh-agent-1'); + const createdSession = { + ...makeSession(), + getSessionData: mock(() => ({ id: sessionId, metadata: {}, config: {} })), + ensureQueryStarted: mock(async () => {}), + messageQueue: { enqueueWithId: mock(async () => {}) }, + } as unknown as AgentSession; + const sessionManager = makeSessionManager(null); + ( + sessionManager.createSession as Mock + ).mockImplementation(async () => sessionId); + (sessionManager.getSessionAsync as Mock) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(createdSession); + const longHorizonAgentRepo = { + getById: mock(() => ({ + id: 'lh-agent-1', + spaceId: mockSpace.id, + handle: 'watcher', + displayName: 'Watcher', + templateKey: null, + status: 'active', + sessionId: null, + instructions: '', + autonomyLevel: null, + model: null, + thinkingLevel: null, + provider: null, + settingSources: null, + toolPermissions: {}, + createdAt: NOW, + updatedAt: NOW, + })), + update: mock(() => {}), + } as unknown as SpaceRuntimeServiceConfig['longHorizonAgentRepo']; + const memoryRepo = { + listCoreMemories: mock(() => []), + search: mock(async () => []), + } as unknown as SpaceRuntimeServiceConfig['memoryRepo']; + const svc = new SpaceRuntimeService({ + ...buildConfigWithSession(sessionManager, createMockSpaceManager(mockSpace)), + longHorizonAgentRepo, + memoryRepo, + }); + + await ( + svc as unknown as { + deliverLongHorizonExternalEvent(args: { + spaceId: string; + agentId: string; + message: string; + idempotencyKey: string; + }): Promise<{ delivered: boolean }>; + } + ).deliverLongHorizonExternalEvent({ + spaceId: mockSpace.id, + agentId: 'lh-agent-1', + message: 'event payload', + idempotencyKey: 'delivery-1', + }); + + expect(createdSession.messageQueue.enqueueWithId).toHaveBeenCalledWith( + 'delivery-1', + 'event payload' + ); + }); + test('long-horizon event sessions refresh existing config before delivery', async () => { const sessionId = longTermAgentSessionId(mockSpace.id, 'lh-agent-1'); const sessionData = {