Skip to content
Open
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
57 changes: 36 additions & 21 deletions packages/daemon/src/lib/space/agents/custom-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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[] = [];
Expand Down
45 changes: 39 additions & 6 deletions packages/daemon/src/lib/space/runtime/space-runtime-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand All @@ -387,7 +392,7 @@ export class SpaceRuntimeService {
): Promise<string | null> {
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;
}

Expand Down Expand Up @@ -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));
Expand All @@ -478,6 +483,7 @@ export class SpaceRuntimeService {
}

private async injectLongTermAgentMessage(
spaceId: string,
session: {
getSessionData(): Session;
ensureQueryStarted(): Promise<void>;
Expand All @@ -488,6 +494,8 @@ export class SpaceRuntimeService {
): Promise<string> {
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,
Expand All @@ -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<string> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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));
});
});
Loading
Loading