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
112 changes: 109 additions & 3 deletions apps/daemon/src/host/daemonMemoryRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,25 @@ import type {
AgentMemoryListOptions,
AgentMemoryStatus,
AgentMemoryKind,
MemoryCandidate,
MemoryRecallItem,
} from "@argos/memory-runtime/types";
import type { MCPToolCall, MCPToolDefinition, MCPToolResponse } from "@argos/shared/types/core/mcp";
import { isAgentMemoryCategory } from "@argos/shared/types/agent-memory";

const MEMORY_TOOL_SERVER_NAME = "agent-memory";

const memoryTool = (
name: string,
description: string,
properties: Record<string, unknown>,
required?: string[],
): MCPToolDefinition => ({
type: "function",
source: "agent",
function: { name, description, parameters: { type: "object", properties, required } },
server: { name: MEMORY_TOOL_SERVER_NAME, icons: "\u{1F9E0}", description: "Argos long-term agent memory" },
});

type BunDB = {
prepare(sql: string): {
Expand Down Expand Up @@ -50,7 +68,8 @@ export class DaemonMemoryRuntime {
return null;
}
},
getEmbeddings: (providerId: string, _modelId: string, texts: string[]) => this.getEmbeddings(providerId, texts),
getEmbeddings: (providerId: string, modelId: string, texts: string[]) =>
this.getEmbeddings(providerId, modelId, texts),
generateText: (providerId: string, modelId: string, prompt: string) =>
this.generateText(providerId, modelId, prompt),
createVectorStore: async (
Expand Down Expand Up @@ -253,9 +272,96 @@ export class DaemonMemoryRuntime {
category: (category as never) ?? null,
status: "pending_embedding" as AgentMemoryStatus,
});
void this.presenter.processPendingEmbeddings(agentId).catch(() => undefined);
return { id: row.id };
}
private async getEmbeddings(providerId: string, texts: string[]): Promise<number[][]> {

// ---- Agent memory tools (Pi worker loop) ----
toolDefinitions(): MCPToolDefinition[] {
return [
memoryTool(
"memory_remember",
"Persist a durable long-term memory (stable fact, preference, or notable event) about the user or project for future sessions.",
{
content: { type: "string" },
kind: { type: "string", enum: ["episodic", "semantic"] },
category: { type: "string" },
importance: { type: "number", minimum: 0, maximum: 1 },
},
["content"],
),
memoryTool("memory_recall", "Recall relevant long-term memories for a query.", {
query: { type: "string" },
limit: { type: "number", minimum: 1, maximum: 20 },
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
memoryTool("memory_forget", "Permanently delete a specific long-term memory by id so it is no longer recalled.", {
memoryId: { type: "string" },
}),
];
}

handlesTool(name: string): boolean {
return this.toolDefinitions().some((tool) => tool.function.name === name);
}

async rememberMemory(
agentId: string,
input: { content: string; kind?: "episodic" | "semantic"; category?: string | null; importance?: number },
): Promise<{ action: "created" | "noop"; id?: string; reason?: string }> {
const candidate: MemoryCandidate = {
kind: input.kind === "episodic" ? "episodic" : "semantic",
content: input.content,
category: typeof input.category === "string" && isAgentMemoryCategory(input.category) ? input.category : null,
importance: input.importance ?? 0.7,
};
const ids = this.presenter.writeMemoriesSync([candidate], { agentId });
if (ids.length > 0) {
void this.presenter.processPendingEmbeddings(agentId).catch(() => undefined);
return { action: "created", id: ids[0] };
}
return { action: "noop", reason: "duplicate" };
}

async recallMemory(agentId: string, query: string): Promise<MemoryRecallItem[]> {
return this.presenter.recall(agentId, query);
}

async forgetMemory(agentId: string, memoryId: string): Promise<boolean> {
return this.presenter.deleteMemory(agentId, memoryId);
}

async callMemoryTool(request: MCPToolCall, agentId: string): Promise<MCPToolResponse> {
const args = JSON.parse(request.function.arguments || "{}") as Record<string, unknown>;
let result: unknown;
switch (request.function.name) {
case "memory_remember":
result = await this.rememberMemory(agentId, {
content: String(args.content ?? ""),
kind: args.kind === "episodic" ? "episodic" : args.kind === "semantic" ? "semantic" : undefined,
category: typeof args.category === "string" ? args.category : null,
importance: typeof args.importance === "number" ? args.importance : undefined,
});
break;
case "memory_recall": {
const items = await this.recallMemory(agentId, String(args.query ?? ""));
const limit = typeof args.limit === "number" ? Math.min(Math.max(Math.trunc(args.limit), 1), 20) : undefined;
result = limit ? items.slice(0, limit) : items;
break;
}
case "memory_forget":
result = await this.forgetMemory(agentId, String(args.memoryId ?? ""));
break;
default:
throw new Error(`Unknown memory tool: ${request.function.name}`);
}
return {
toolCallId: request.id,
content: [{ type: "text", text: JSON.stringify(result) }],
toolResult: result,
};
}

private async getEmbeddings(providerId: string, modelId: string, texts: string[]): Promise<number[][]> {
const provider = this.resolveProvider(providerId);
let base = provider.baseUrl.replace(/\/+$/, "");
if (!base.endsWith("/v1")) base += "/v1";
Expand All @@ -264,7 +370,7 @@ export class DaemonMemoryRuntime {
const response = await fetch(base, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
body: JSON.stringify({ model: "text-embedding-3-small", input: texts }),
body: JSON.stringify({ model: modelId, input: texts }),
});
if (!response.ok) throw new Error(`Embeddings API error (${response.status})`);
const data = (await response.json()) as { data: Array<{ embedding: number[] }> };
Expand Down
50 changes: 40 additions & 10 deletions apps/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,13 @@ export async function startDaemon(options?: {
const { BunSessionRepository } = await import("./host/bun-session-repository");
const sessionRepository = new BunSessionRepository(db, eventPublisher);
const orchestrationRuntime = new ArgosOrchestrationRuntime(db, () => configPresenter.listAgents());
const memoryRuntime = new DaemonMemoryRuntime({
db,
configPresenter,
dataDir: paths.getDataDir(),
});
memoryRuntime.presenter.startBackgroundMaintenance();
logger.info("[daemon] Memory runtime initialized");

const sessions = await sessionRepository.list();
logger.info(`[daemon] Restored ${sessions.length} session(s) from database`);
Expand All @@ -316,12 +323,31 @@ export async function startDaemon(options?: {
const scoped = Array.isArray(allowed)
? definitions.filter((tool) => allowed.includes(tool.server.name))
: definitions;
return agentConfig?.orchestrationEnabled ? [...scoped, ...orchestrationRuntime.definitions()] : scoped;
const orchestration = agentConfig?.orchestrationEnabled
? [...scoped, ...orchestrationRuntime.definitions()]
: scoped;
return agentConfig?.memoryEnabled === true
? [...orchestration, ...memoryRuntime.toolDefinitions()]
: orchestration;
},
callTool: async (request) => {
if (orchestrationRuntime.handles((request as any).function?.name)) {
return orchestrationRuntime.call(request as any);
}
if (memoryRuntime.handlesTool((request as any).function?.name)) {
const sessionId = (request as any).conversationId as string | undefined;
const session = sessionId ? await sessionRepository.get(sessionId) : null;
if (!session?.agentId) {
throw new Error("Memory tool requires an active session with an agent.");
}
const agentConfig = await configPresenter.resolveArgosAgentConfig(session.agentId);
if (agentConfig?.memoryEnabled !== true) {
throw new Error("Memory tools are disabled for this agent.");
}
return memoryRuntime.callMemoryTool(request as any, session.agentId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return mcpRuntime.callApprovedTool(request);
},
callTool: (request) =>
orchestrationRuntime.handles((request as any).function?.name)
? orchestrationRuntime.call(request as any)
: mcpRuntime.callApprovedTool(request),
},
);
const acpProviderExecutionPort = new AcpProviderExecutionPort(configPresenter, sessionRepository, eventPublisher, {
Expand Down Expand Up @@ -705,11 +731,6 @@ export async function startDaemon(options?: {
providerExecutionPort,
});
scheduledTasks.start();
const memoryRuntime = new DaemonMemoryRuntime({
db,
configPresenter,
dataDir: paths.getDataDir(),
});
const remoteControlRuntime = new DaemonRemoteControlRuntime({
configPresenter,
sessionRepository,
Expand Down Expand Up @@ -1005,6 +1026,13 @@ export async function startDaemon(options?: {
} catch {
logger.warn("[daemon] Failed to shut down Pi workers cleanly");
}
try {
memoryRuntime.presenter.stopBackgroundMaintenance();
await memoryRuntime.presenter.dispose();
logger.info("[daemon] Memory runtime stopped");
} catch {
logger.warn("[daemon] Failed to stop memory runtime cleanly");
}
try {
db.close();
logger.info("[daemon] Database closed");
Expand All @@ -1017,6 +1045,8 @@ export async function startDaemon(options?: {
port: serverPort,
close: async () => {
scheduledTasks.stop();
memoryRuntime.presenter.stopBackgroundMaintenance();
await memoryRuntime.presenter.dispose().catch(() => undefined);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await remoteControlRuntime.destroy();
await pluginPresenter.shutdown();
await piProviderExecutionPort.dispose();
Expand Down
126 changes: 124 additions & 2 deletions apps/daemon/test/daemonMemoryRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ function createFakeDb() {
return { changes: row ? 1 : 0 };
}

if (sql.includes("UPDATE agent_memory SET status = ?")) {
const [status, embeddingId, embeddingDim, embeddingModel, id] = params as any[];
const row = memories.get(String(id));
if (row) {
row.status = status;
row.embedding_id = embeddingId;
row.embedding_dim = embeddingDim;
row.embedding_model = embeddingModel;
}
return { changes: row ? 1 : 0 };
}

if (sql.includes("DELETE FROM agent_memory WHERE id = ?")) {
const [id] = params as any[];
return { changes: memories.delete(String(id)) ? 1 : 0 };
}

return { changes: 0 };
},
get: (...params: unknown[]) => {
Expand All @@ -87,12 +104,30 @@ function createFakeDb() {
const count = Array.from(memories.values()).filter((row) => row.agent_id === agentId).length;
return { count };
}
if (sql.includes("provenance_key = ?")) {
const [agentId, key] = params as any[];
return Array.from(memories.values()).find((row) => row.agent_id === agentId && row.provenance_key === key);
}
return undefined;
},
all: (...params: unknown[]) => {
if (sql.includes("agent_memory_fts MATCH")) {
throw new Error("FTS unavailable");
}
if (sql.includes("status = 'pending_embedding' AND agent_id = ?")) {
const [agentId, limit] = params as any[];
return Array.from(memories.values())
.filter((row) => row.status === "pending_embedding" && row.agent_id === agentId)
.sort((a, b) => a.created_at - b.created_at)
.slice(0, limit ?? 50);
}
if (sql.includes("status = 'pending_embedding'")) {
const [limit] = params as any[];
return Array.from(memories.values())
.filter((row) => row.status === "pending_embedding")
.sort((a, b) => a.created_at - b.created_at)
.slice(0, limit ?? 50);
}
if (sql.includes("SELECT * FROM agent_memory WHERE agent_id = ? AND content LIKE ?")) {
const [agentId, likePattern] = params as any[];
const needle = String(likePattern).replace(/%/g, "").toLowerCase();
Expand Down Expand Up @@ -139,14 +174,15 @@ describe("DaemonMemoryRuntime", () => {
});
}

it("writes memory rows and reports headless status", async () => {
it("writes memory rows and reports status", async () => {
const runtime = createRuntime();

const result = await runtime.addMemory("agent-1", "Remember this", "semantic", 0.8, "note");
expect(result.id).toEqual(expect.any(String));
await runtime.presenter.processPendingEmbeddings("agent-1");
expect(runtime.presenter.getStatus("agent-1")).toEqual({
total: 1,
pendingEmbedding: 1,
pendingEmbedding: 0,
hasPersona: false,
reindexing: false,
});
Expand All @@ -164,4 +200,90 @@ describe("DaemonMemoryRuntime", () => {
]),
);
});

it("drains pending embeddings to fts_only when no embedding model is configured", async () => {
const runtime = createRuntime();
await runtime.addMemory("agent-1", "Drain this memory", "semantic", 0.5, "note");
await runtime.presenter.processPendingEmbeddings("agent-1");

expect(runtime.presenter.getStatus("agent-1").pendingEmbedding).toBe(0);
const rows = runtime.presenter.listMemories("agent-1");
expect(rows).toEqual(
expect.arrayContaining([expect.objectContaining({ content: "Drain this memory", status: "fts_only" })]),
);
});

it("exposes agent memory tools", () => {
const runtime = createRuntime();
const definitions = runtime.toolDefinitions();
const names = definitions.map((tool) => tool.function.name);
expect(names).toEqual(["memory_remember", "memory_recall", "memory_forget"]);
expect(definitions.every((tool) => tool.server.name === "agent-memory")).toBe(true);
expect(runtime.handlesTool("memory_remember")).toBe(true);
expect(runtime.handlesTool("memory_recall")).toBe(true);
expect(runtime.handlesTool("memory_forget")).toBe(true);
expect(runtime.handlesTool("unrelated_tool")).toBe(false);
});

it("remembers and forgets a memory through the agent tools", async () => {
const runtime = createRuntime();

const remembered = await runtime.rememberMemory("agent-1", {
content: "The user prefers dark mode.",
kind: "semantic",
importance: 0.8,
});
expect(remembered.action).toBe("created");
expect(remembered.id).toEqual(expect.any(String));

await expect(runtime.recallMemory("agent-1", "dark mode")).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
content: "The user prefers dark mode.",
}),
]),
);

const forgot = await runtime.forgetMemory("agent-1", remembered.id as string);
expect(forgot).toBe(true);
expect(runtime.presenter.listMemories("agent-1").length).toBe(0);
});

it("dispatches memory tools through callMemoryTool", async () => {
const runtime = createRuntime();
const response = await runtime.callMemoryTool(
{
id: "tool-call-1",
type: "function",
function: {
name: "memory_remember",
arguments: JSON.stringify({ content: "Remember this fact." }),
},
},
"agent-1",
);
expect(response.toolCallId).toBe("tool-call-1");
expect(response.toolResult).toEqual(expect.objectContaining({ action: "created" }));
});

it("honors the recall limit argument", async () => {
const runtime = createRuntime();
await runtime.addMemory("agent-1", "Limit test one", "semantic", 0.5, "note");
await runtime.addMemory("agent-1", "Limit test two", "semantic", 0.5, "note");
await runtime.addMemory("agent-1", "Limit test three", "semantic", 0.5, "note");

const response = await runtime.callMemoryTool(
{
id: "tool-call-2",
type: "function",
function: {
name: "memory_recall",
arguments: JSON.stringify({ query: "limit test", limit: 2 }),
},
},
"agent-1",
);
const recalled = response.toolResult as Array<{ content: string }>;
expect(recalled).toHaveLength(2);
});
});
Loading
Loading