diff --git a/apps/daemon/src/host/daemonMemoryRuntime.ts b/apps/daemon/src/host/daemonMemoryRuntime.ts index 15e720f4f..e0f2902dd 100644 --- a/apps/daemon/src/host/daemonMemoryRuntime.ts +++ b/apps/daemon/src/host/daemonMemoryRuntime.ts @@ -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, + 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): { @@ -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 ( @@ -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 { + + // ---- 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 }, + }), + 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 { + return this.presenter.recall(agentId, query); + } + + async forgetMemory(agentId: string, memoryId: string): Promise { + return this.presenter.deleteMemory(agentId, memoryId); + } + + async callMemoryTool(request: MCPToolCall, agentId: string): Promise { + const args = JSON.parse(request.function.arguments || "{}") as Record; + 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 { const provider = this.resolveProvider(providerId); let base = provider.baseUrl.replace(/\/+$/, ""); if (!base.endsWith("/v1")) base += "/v1"; @@ -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[] }> }; diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index dc018f4fd..220188b84 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -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`); @@ -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); + } + 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, { @@ -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, @@ -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"); @@ -1017,6 +1045,8 @@ export async function startDaemon(options?: { port: serverPort, close: async () => { scheduledTasks.stop(); + memoryRuntime.presenter.stopBackgroundMaintenance(); + await memoryRuntime.presenter.dispose().catch(() => undefined); await remoteControlRuntime.destroy(); await pluginPresenter.shutdown(); await piProviderExecutionPort.dispose(); diff --git a/apps/daemon/test/daemonMemoryRuntime.test.ts b/apps/daemon/test/daemonMemoryRuntime.test.ts index e94ead633..bf1098515 100644 --- a/apps/daemon/test/daemonMemoryRuntime.test.ts +++ b/apps/daemon/test/daemonMemoryRuntime.test.ts @@ -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[]) => { @@ -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(); @@ -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, }); @@ -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); + }); }); diff --git a/docs/features/memory-real-implementation/plan.md b/docs/features/memory-real-implementation/plan.md new file mode 100644 index 000000000..13d61657d --- /dev/null +++ b/docs/features/memory-real-implementation/plan.md @@ -0,0 +1,67 @@ +# Real Agent Memory Implementation — Plan + +## Approach + +Single feature PR: make the daemon the real memory host. The `MemoryPresenter` + +`MemoryVectorStore` (DuckDB + `vss`) already provide the full write/embed/recall/consolidation +engine; the gaps are (a) the daemon never triggers embedding, (b) embeddings hard-code a model, +(c) no memory tools in the agent loop, (d) no maintenance sweep, (e) UI only reachable in +Settings with broken props. + +## Affected Surfaces & Data Flow + +```text +Chat UI (MemoryManagerDialog) ──memory.add──▶ daemonDispatcher ──▶ DaemonMemoryRuntime.addMemory + │ presenter.insert (status=pending_embedding) + ▼ + presenter.processPendingEmbeddings() + │ getEmbeddings(providerId, modelId, texts) + ▼ + MemoryVectorStore.upsert (DuckDB vss) + │ +Pi worker (agent loop) ──memory_remember/recall/forget──▶ index.ts callTool + │ (resolve agentId from session) + ▼ + DaemonMemoryRuntime.remember/recall/forget +``` + +- **Tool contract:** memory tools are ordinary MCP-style definitions (server `agent-memory`). + They travel with `config.tools` (they are not orchestration tools) and dispatch through the + existing `mcpRequest` → `callTool` path. +- **Gating:** `apps/daemon/src/index.ts` `listTools` appends `memoryRuntime.toolDefinitions()` + only when `agentConfig?.memoryEnabled === true` (mirrors orchestration gating on + `orchestrationEnabled`). `callTool` routes `memory_*` names to the memory runtime. +- **Embedding model:** `drainPendingEmbeddings` already passes the agent's + `memoryEmbedding.{providerId,modelId}`; `DaemonMemoryRuntime.getEmbeddings` must stop dropping + `modelId`. +- **Maintenance:** call `memoryRuntime.presenter.startBackgroundMaintenance()` at daemon startup + (near the existing `memoryRuntime` construction). +- **UI:** + - `MemoryManagerDialog` is reused from the chat top bar (import via `#settings/components/...`). + - `ArgosAgentsSettings` passes `memoryEnabled` + `hasEmbeddingConfigured` (derived from the + form's embedding model selection) into `MemoryManagerDialog`. + +## Compatibility + +- No route contract changes; `MemoryAddResultDto` already supports the outcomes produced. +- The desktop route proxy for memory routes is untouched. +- Existing memory manager behavior (kind/category/importance, search, delete, clear) preserved. + +## Test Strategy + +- Extend `apps/daemon/test/daemonMemoryRuntime.test.ts`: + - `addMemory` leaves the row for embedding and triggers a drain (assert `processPendingEmbeddings` + ran / status transitions when embeddings are configured). + - `getEmbeddings` uses the passed `modelId` (assert request body model). +- Add coverage for the new memory tool surface (`toolDefinitions` shape, `handlesTool`, + `callTool` remembering/recalling/forgetting an agent). +- Run `bun test` for daemon, `bun run typecheck`, `bun run lint`, `bun run format`. + +## Risks + +- `vss` extension not bundled on disk (relies on online `INSTALL vss`) — pre-existing; the + presenter falls back to FTS-only via `isUsable()`. Not addressed here. +- Embedding provider without a `baseUrl` throws → memories become `fts_only` (already handled in + `drainPendingEmbeddings`). +- Pi worker tool-list signature changes when memory tools toggle; existing signature check in + `getWorker` already includes `tools`, so toggling memory in config respawns the worker. \ No newline at end of file diff --git a/docs/features/memory-real-implementation/spec.md b/docs/features/memory-real-implementation/spec.md new file mode 100644 index 000000000..0b1c9160b --- /dev/null +++ b/docs/features/memory-real-implementation/spec.md @@ -0,0 +1,73 @@ +# Real Agent Memory Implementation + +## User Need + +Long-term agent memory is largely inert in the current runtime. The `MemoryPresenter` / +`MemoryVectorStore` (DuckDB + `vss`) machinery exists, but it is only reachable from the +Settings → Agent → "Manage memory" dialog, and even that path never actually embeds memories. +Users cannot reliably create, recall, or manage memory, and agents cannot use memory during a +conversation. + +As a user, I want memory to actually work end-to-end: + +- Memories I add (or that agents record) get embedded into the DuckDB vector store and are + semantically recalled across sessions. +- Agents in the daemon loop can persist and recall memory with dedicated tools. +- I can view, add, search, and remove memories from the main chat experience, not only buried in + Settings. + +## Goal + +Make the agent memory feature fully functional in the daemon runtime and surface it in the UI: + +1. Fix the daemon memory runtime so adding a memory triggers embedding, and the configured + embedding model is respected. +2. Expose `memory_remember`, `memory_recall`, `memory_forget` agent tools to the daemon agent + (Pi worker) loop, gated on the agent having memory enabled. +3. Start background maintenance (consolidation) for memory agents. +4. Add a memory management surface to the main chat UI and fix the broken props on the existing + Settings dialog. + +## Acceptance Criteria + +- **Add → embed:** Calling the `memory.add` route (Settings or Chat UI) inserts a memory and + drains its embeddings so its status transitions from `pending_embedding` to `embedded` + (or `fts_only` when no embedding model is configured). +- **Correct embedding model:** `DaemonMemoryRuntime` requests embeddings with the agent's + configured `memoryEmbedding.modelId`, not a hard-coded model. +- **Agent tools:** When an agent's config has `memoryEnabled === true`, the Pi worker receives + `memory_remember`, `memory_recall`, and `memory_forget`; invoking them writes/recalls/deletes + memories for that agent's session. When `memoryEnabled` is false/undefined, the tools are not + exposed. +- **Background maintenance:** The daemon starts `MemoryPresenter.startBackgroundMaintenance()` + so consolidation sweep runs for registered memory agents. +- **Shutdown lifecycle:** Both daemon shutdown paths (`close()` and graceful signal shutdown) + stop background maintenance and dispose the memory presenter before the database is closed. +- **Chat UI:** The main chat top bar has a memory button that opens a memory manager for the + active session's agent (add / list / search / delete / clear). +- **Settings UI:** The Memory dialog passes `memoryEnabled` and `hasEmbeddingConfigured`, so the + disabled banner and the "embeddings not configured" banner behave correctly. + +## Constraints + +- Follow existing daemon tool patterns (modeled on `ArgosOrchestrationRuntime`): tool definitions + are MCP-style and dispatched in `apps/daemon/src/index.ts` `listTools` / `callTool`, gated on + agent config. +- Memory routes are not desktop-only; the renderer reaches them over the WebSocket bridge. +- Business code under `packages/ui/src` must not import from `#api/legacy`; importing the shared + settings components via `#settings/components/...` is the established pattern. +- Must pass `bun run typecheck`, `bun run lint`, `bun run format`, and relevant tests. + +## Non-Goals + +- Automatic chat-message extraction (`MemoryPresenter.extractAndStore`) is not wired into the + daemon loop in this change (agents write via explicit tools; the designer can add background + extraction later). +- Persona evolution / reflection tuning beyond what the existing runtime already does. +- Migrating the desktop-only `ToolPresenter`/`AgentToolManager` memory duplicate; the daemon is the + single host. + +## Open Questions + +- [RESOLVED] UI placement → Chat top bar + fixed Settings dialog (user selected). +- [RESOLVED] Tool gating → gate on agent `memoryEnabled` (user selected). diff --git a/docs/features/memory-real-implementation/tasks.md b/docs/features/memory-real-implementation/tasks.md new file mode 100644 index 000000000..8893384d6 --- /dev/null +++ b/docs/features/memory-real-implementation/tasks.md @@ -0,0 +1,23 @@ +# Real Agent Memory Implementation — Tasks + +Feature: `memory-real-implementation` + +## Backend + +- [x] `T1.1` `DaemonMemoryRuntime.addMemory` triggers `presenter.processPendingEmbeddings(agentId)` after insert. +- [x] `T1.2` `DaemonMemoryRuntime.getEmbeddings` uses the passed `modelId` (no hard-coded model). +- [x] `T1.3` Add `DaemonMemoryRuntime.rememberMemory` / `recallMemory` / `forgetMemory` helpers (writeMemoriesSync + drain, presenter.recall, presenter.deleteMemory). +- [x] `T1.4` Add `DaemonMemoryRuntime.toolDefinitions()` / `handlesTool(name)` / `callTool(request, agentId)` (server `agent-memory`; `memory_remember`, `memory_recall`, `memory_forget`). +- [x] `T2.1` `apps/daemon/src/index.ts`: start background maintenance after memoryRuntime construction. +- [x] `T2.2` `apps/daemon/src/index.ts` `listTools`: append memory tools when `agentConfig?.memoryEnabled === true`. +- [x] `T2.3` `apps/daemon/src/index.ts` `callTool`: dispatch `memory_*` to the memory runtime (resolve agentId from `request.conversationId`). + +## UI + +- [x] `T3.1` `ArgosAgentsSettings.tsx` passes `memoryEnabled` and `hasEmbeddingConfigured` to `MemoryManagerDialog`. +- [x] `T3.2` `ChatTopBar.tsx` adds a memory button that opens `MemoryManagerDialog` for the active session's agent. + +## Tests & Validation + +- [x] `T4.1` Extend `apps/daemon/test/daemonMemoryRuntime.test.ts` for add→drain, modelId usage, tool surface. +- [x] `T5.1` Run `bun run format`, `bun run lint`, `bun run typecheck`, daemon tests; fix issues. \ No newline at end of file diff --git a/packages/ui/settings/components/ArgosAgentsSettings.tsx b/packages/ui/settings/components/ArgosAgentsSettings.tsx index 242a01933..1b4b59987 100644 --- a/packages/ui/settings/components/ArgosAgentsSettings.tsx +++ b/packages/ui/settings/components/ArgosAgentsSettings.tsx @@ -1844,6 +1844,8 @@ export default function ArgosAgentsSettings() { onOpenChange={setMemoryDialogOpen} agentId={selectedAgentId} agentName={selectedAgent?.name} + memoryEnabled={form.memoryEnabled} + hasEmbeddingConfigured={Boolean(form.memoryEmbeddingProviderId && form.memoryEmbeddingModelId)} /> )} diff --git a/packages/ui/src/components/chat/ChatTopBar.tsx b/packages/ui/src/components/chat/ChatTopBar.tsx index f5cd27ec1..b690da233 100644 --- a/packages/ui/src/components/chat/ChatTopBar.tsx +++ b/packages/ui/src/components/chat/ChatTopBar.tsx @@ -26,6 +26,8 @@ import { DialogTitle, } from "#shadcn/components/ui/dialog"; import AgentTransferDialog from "#/components/agent/AgentTransferDialog"; +import { MemoryManagerDialog } from "#settings/components/MemoryManagerDialog"; +import { createConfigClient } from "#api/ConfigClient"; import { useAgentStore } from "#/stores/ui/agent"; import { useSessionStore, getNewConversationTargetAgentId } from "#/stores/ui/session"; import { useSidepanelStore } from "#/stores/ui/sidepanel"; @@ -58,6 +60,11 @@ const ChatTopBar: FC = ({ const [moveDialogOpen, setMoveDialogOpen] = useState(false); const [moveDialogBusy, setMoveDialogBusy] = useState(false); const [moveDialogError, setMoveDialogError] = useState(null); + const [memoryDialogOpen, setMemoryDialogOpen] = useState(false); + const [memoryCapabilities, setMemoryCapabilities] = useState<{ + memoryEnabled?: boolean; + hasEmbeddingConfigured?: boolean; + }>({}); const [renameValue, setRenameValue] = useState(""); const renameInputRef = useRef(null); @@ -99,6 +106,27 @@ const ChatTopBar: FC = ({ () => !isReadOnly && currentSession?.sessionKind === "regular" && currentSession?.status !== "working", [isReadOnly, currentSession?.sessionKind, currentSession?.status], ); + const canManageMemory = useMemo( + () => !isReadOnly && Boolean(currentSession?.agentId) && currentAgent?.type === "argos", + [isReadOnly, currentSession?.agentId, currentAgent?.type], + ); + + const openMemoryDialog = useCallback(async () => { + const agentId = currentSession?.agentId; + if (!agentId) return; + setMemoryCapabilities({}); + try { + const configClient = createConfigClient(); + const config = await configClient.resolveArgosAgentConfig(agentId); + setMemoryCapabilities({ + memoryEnabled: config?.memoryEnabled, + hasEmbeddingConfigured: Boolean(config?.memoryEmbedding?.providerId && config?.memoryEmbedding?.modelId), + }); + } catch { + setMemoryCapabilities({}); + } + setMemoryDialogOpen(true); + }, [currentSession?.agentId]); const normalizedRenameValue = useMemo(() => renameValue.trim(), [renameValue]); const canSubmitRename = useMemo( () => normalizedRenameValue.length > 0 && normalizedRenameValue !== currentTitle.trim(), @@ -374,6 +402,18 @@ const ChatTopBar: FC = ({
+ {canManageMemory && ( + + )}