diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index eda035e72a4..5ad5e35d7e7 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -499,6 +499,41 @@ Notes: - For stricter gating, set `agents.list[].groupChat.mentionPatterns` and keep group allowlists enabled for the channel. +## Per-Agent Memory Search (QMD Extra Collections) + +Agents with shared workspaces already share `MEMORY.md` and `memory/*.md` files. +However, session transcripts are indexed per-agent in separate QMD collections. + +To let one agent search another agent's session transcripts, add +`memorySearch.qmd.extraCollections`: + +```json5 +{ + agents: { + list: [ + { + id: "main", + memorySearch: { + qmd: { + extraCollections: [ + { + path: "~/.openclaw/agents/family/qmd/sessions", + pattern: "**/*.md", + }, + ], + }, + }, + }, + { id: "family" }, // no extraCollections: can only search its own sessions + ], + }, +} +``` + +This gives directional control: each agent opts in to what it can search. The +family agent above cannot search the main agent's sessions, but main can search +family's. + ## Per-Agent Sandbox and Tool Configuration Each agent can have its own sandbox and tool restrictions: diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 11ea717513a..50dca8b596f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1410,6 +1410,7 @@ scripts/sandbox-browser-setup.sh # optional browser image - `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions. - `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI. - `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`. +- `memorySearch.qmd.extraCollections`: additional QMD collections for this agent's memory search. Useful for cross-agent session search (for example, letting one agent search another agent's session transcripts). Each entry: `{ path, pattern?, name? }`. - `subagents.allowAgents`: allowlist of agent ids for `sessions_spawn` (`["*"]` = any; default: same agent only). - Sandbox inheritance guard: if the requester session is sandboxed, `sessions_spawn` rejects targets that would run unsandboxed. diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 7a6053fd01c..e2c28954b28 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -51,6 +51,31 @@ describe("config schema regressions", () => { expect(res.ok).toBe(true); }); + it("accepts per-agent memorySearch.qmd.extraCollections", () => { + const res = validateConfigObject({ + agents: { + list: [ + { + id: "agent-b", + memorySearch: { + qmd: { + extraCollections: [ + { + name: "family-sessions", + path: "~/.openclaw/agents/family/qmd/sessions", + pattern: "**/*.md", + }, + ], + }, + }, + }, + ], + }, + }); + + expect(res.ok).toBe(true); + }); + it("accepts safe iMessage remoteHost", () => { const res = validateConfigObject({ channels: { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 947726bd7e8..4386ce2c364 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -788,6 +788,8 @@ export const FIELD_HELP: Record = { 'Selects which multimodal file types are indexed from extraPaths: "image", "audio", or "all". Keep this narrow to avoid indexing large binary corpora unintentionally.', "agents.defaults.memorySearch.multimodal.maxFileBytes": "Sets the maximum bytes allowed per multimodal file before it is skipped during memory indexing. Use this to cap upload cost and indexing latency, or raise it for short high-quality audio clips.", + "agents.list[].memorySearch.qmd.extraCollections": + "Per-agent extra QMD collections to include in memory search.", "agents.defaults.memorySearch.experimental.sessionMemory": "Indexes session transcripts into memory search so responses can reference prior chat turns. Keep this off unless transcript recall is needed, because indexing cost and storage usage both increase.", "agents.defaults.memorySearch.provider": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 53317e2fcd2..9f234f605fd 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -319,6 +319,7 @@ export const FIELD_LABELS: Record = { "agents.defaults.memorySearch.multimodal.enabled": "Enable Memory Search Multimodal", "agents.defaults.memorySearch.multimodal.modalities": "Memory Search Multimodal Modalities", "agents.defaults.memorySearch.multimodal.maxFileBytes": "Memory Search Multimodal Max File Bytes", + "agents.list[].memorySearch.qmd.extraCollections": "Agent QMD Extra Collections", "agents.defaults.memorySearch.experimental.sessionMemory": "Memory Search Session Index (Experimental)", "agents.defaults.memorySearch.provider": "Memory Search Provider", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index f42fa365f6f..f5cc5da842c 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -328,6 +328,14 @@ export type MemorySearchConfig = { /** Max bytes allowed per multimodal file before it is skipped. */ maxFileBytes?: number; }; + /** Per-agent QMD extra collections. */ + qmd?: { + extraCollections?: Array<{ + name?: string; + path: string; + pattern?: string; + }>; + }; /** Experimental memory search settings. */ experimental?: { /** Enable session transcript indexing (experimental, default: false). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 10f0f8637e9..7e4be3b1e94 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -598,6 +598,22 @@ export const MemorySearchSchema = z }) .strict() .optional(), + qmd: z + .object({ + extraCollections: z + .array( + z + .object({ + name: z.string().optional(), + path: z.string(), + pattern: z.string().optional(), + }) + .strict(), + ) + .optional(), + }) + .strict() + .optional(), experimental: z .object({ sessionMemory: z.boolean().optional(), diff --git a/src/memory/backend-config.test.ts b/src/memory/backend-config.test.ts index 61fa62f9316..542652860af 100644 --- a/src/memory/backend-config.test.ts +++ b/src/memory/backend-config.test.ts @@ -108,6 +108,103 @@ describe("resolveMemoryBackendConfig", () => { expect(devNames.has("workspace-dev")).toBe(true); }); + it("adds per-agent qmd extra collections only for the configured agent", () => { + const cfg = { + agents: { + defaults: { workspace: "/workspace/root" }, + list: [ + { + id: "main", + default: true, + workspace: "/workspace/root", + }, + { + id: "agent-b", + workspace: "/workspace/agent-b", + memorySearch: { + qmd: { + extraCollections: [ + { + name: "family-sessions", + path: "/workspace/family/qmd/sessions", + pattern: "**/*.md", + }, + ], + }, + }, + }, + ], + }, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + }, + }, + } as OpenClawConfig; + + const mainResolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + const agentBResolved = resolveMemoryBackendConfig({ cfg, agentId: "agent-b" }); + + const mainPaths = new Set((mainResolved.qmd?.collections ?? []).map((entry) => entry.path)); + const agentBCollections = agentBResolved.qmd?.collections ?? []; + + const expectedPath = path.resolve("/workspace/family/qmd/sessions"); + expect(mainPaths.has(expectedPath)).toBe(false); + expect( + agentBCollections.some( + (entry) => + entry.path === expectedPath && entry.pattern === "**/*.md" && entry.kind === "custom", + ), + ).toBe(true); + }); + + it("uses explicit extraCollection names without agent-scoping", () => { + const cfg = { + agents: { + defaults: { workspace: "/tmp/memory-test" }, + list: [ + { + id: "main", + default: true, + workspace: "/workspace/main", + memorySearch: { + qmd: { + extraCollections: [ + { + name: "sessions-family", + path: "/workspace/family/sessions", + pattern: "**/*.md", + }, + { + path: "/workspace/auto-named", + }, + ], + }, + }, + }, + ], + }, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + }, + }, + } as OpenClawConfig; + + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + const collections = resolved.qmd?.collections ?? []; + + const explicit = collections.find((c) => c.path === path.resolve("/workspace/family/sessions")); + expect(explicit).toBeDefined(); + expect(explicit?.name).toBe("sessions-family"); + + const autoNamed = collections.find((c) => c.path === path.resolve("/workspace/auto-named")); + expect(autoNamed).toBeDefined(); + expect(autoNamed?.name).toContain("main"); + }); + it("resolves qmd update timeout overrides", () => { const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } }, diff --git a/src/memory/backend-config.ts b/src/memory/backend-config.ts index da1c13819a3..82583015ce9 100644 --- a/src/memory/backend-config.ts +++ b/src/memory/backend-config.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import { resolveAgentConfig, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { parseDurationMs } from "../cli/parse-duration.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionSendPolicyConfig } from "../config/types.base.js"; @@ -272,6 +272,43 @@ function resolveMcporterConfig(raw?: MemoryQmdMcporterConfig): ResolvedQmdMcport return parsed; } +function resolveAgentExtraCollections( + rawCollections: Array<{ name?: string; path: string; pattern?: string }> | undefined, + workspaceDir: string, + existing: Set, + agentId: string, +): ResolvedQmdCollection[] { + if (!rawCollections?.length) { + return []; + } + const collections: ResolvedQmdCollection[] = []; + rawCollections.forEach((entry, index) => { + const trimmedPath = entry?.path?.trim(); + if (!trimmedPath) { + return; + } + let resolved: string; + try { + resolved = resolvePath(trimmedPath, workspaceDir); + } catch { + return; + } + const pattern = entry.pattern?.trim() || "**/*.md"; + const explicitName = entry.name?.trim(); + const baseName = explicitName + ? sanitizeName(explicitName) + : scopeCollectionBase(`agent-extra-${index + 1}`, agentId); + const name = ensureUniqueName(baseName, existing); + collections.push({ + name, + path: resolved, + pattern, + kind: "custom", + }); + }); + return collections; +} + function resolveDefaultCollections( include: boolean, workspaceDir: string, @@ -308,9 +345,17 @@ export function resolveMemoryBackendConfig(params: { const qmdCfg = params.cfg.memory?.qmd; const includeDefaultMemory = qmdCfg?.includeDefaultMemory !== false; const nameSet = new Set(); + const agentQmdExtraCollections = resolveAgentConfig(params.cfg, params.agentId)?.memorySearch?.qmd + ?.extraCollections; const collections = [ ...resolveDefaultCollections(includeDefaultMemory, workspaceDir, nameSet, params.agentId), ...resolveCustomPaths(qmdCfg?.paths, workspaceDir, nameSet, params.agentId), + ...resolveAgentExtraCollections( + agentQmdExtraCollections, + workspaceDir, + nameSet, + params.agentId, + ), ]; const rawCommand = qmdCfg?.command?.trim() || "qmd"; diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index f283459c61d..aa98819a887 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -450,6 +450,77 @@ describe("QmdMemoryManager", () => { expect(addCalls).toHaveLength(0); }); + it("remaps extra collections to existing qmd collection names when add conflicts on path", async () => { + const familySessionsPath = path.join(tmpRoot, "family-sessions"); + await fs.mkdir(familySessionsPath, { recursive: true }); + cfg = { + agents: { + list: [ + { + id: agentId, + default: true, + workspace: workspaceDir, + memorySearch: { + qmd: { + extraCollections: [{ path: familySessionsPath, pattern: "**/*.md" }], + }, + }, + }, + ], + }, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "list") { + const child = createMockChild({ autoClose: false }); + const jsonData = JSON.stringify([ + { name: "workspace-main", path: workspaceDir, pattern: "**/*.md" }, + { name: "sessions-family", path: familySessionsPath, pattern: "**/*.md" }, + ]); + emitAndClose(child, "stdout", jsonData, 0); + return child; + } + if (args[0] === "collection" && args[1] === "add") { + const pathArg = args[2] ?? ""; + if (path.resolve(pathArg) === path.resolve(familySessionsPath)) { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stderr", "A collection already exists for this path and pattern", 1); + return child; + } + return createMockChild(); + } + if (args[0] === "collection" && args[1] === "remove") { + // Rebind attempt: simulate removal failure to trigger alias fallback. + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stderr", "collection not found", 1); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "full" }); + + // Verify the remap was logged + expect(logInfoMock).toHaveBeenCalledWith( + expect.stringContaining("remapped to existing collection sessions-family"), + ); + + // Verify the aliased QMD-side name is in collectionRoots so search results + // from that collection won't be silently dropped by toDocLocation(). + const roots = (manager as unknown as { collectionRoots: Map }).collectionRoots; + expect(roots.has("sessions-family")).toBe(true); + + await manager.close(); + }); + it("migrates unscoped legacy collections before adding scoped names", async () => { cfg = { ...cfg, diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 46a80156677..d4bf5f3a70d 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -28,6 +28,7 @@ import type { type SqliteDatabase = import("node:sqlite").DatabaseSync; import type { ResolvedMemoryBackendConfig, + ResolvedQmdCollection, ResolvedQmdConfig, ResolvedQmdMcporterConfig, } from "./backend-config.js"; @@ -147,8 +148,9 @@ export class QmdMemoryManager implements MemorySearchManager { private readonly xdgCacheHome: string; private readonly indexPath: string; private readonly env: NodeJS.ProcessEnv; - private readonly managedCollectionNames: string[]; + private managedCollectionNames: string[]; private readonly collectionRoots = new Map(); + private readonly collectionAliases = new Map(); private readonly sources = new Set(); private readonly docPathCache = new Map< string, @@ -275,10 +277,12 @@ export class QmdMemoryManager implements MemorySearchManager { private bootstrapCollections(): void { this.collectionRoots.clear(); + this.collectionAliases.clear(); this.sources.clear(); for (const collection of this.qmd.collections) { const kind: MemorySource = collection.kind === "sessions" ? "sessions" : "memory"; this.collectionRoots.set(collection.name, { path: collection.path, kind }); + this.collectionAliases.set(collection.name, collection.name); this.sources.add(kind); } } @@ -322,13 +326,29 @@ export class QmdMemoryManager implements MemorySearchManager { addErrorMessage: message, }); if (!rebound) { - log.warn(`qmd collection add skipped for ${collection.name}: ${message}`); + // Fallback: try path-only alias (less strict than path+pattern rebind). + const aliasName = this.findCollectionByPath(collection, existing); + if (aliasName) { + this.collectionAliases.set(collection.name, aliasName); + if (!this.collectionRoots.has(aliasName)) { + const rootKind: MemorySource = + collection.kind === "sessions" ? "sessions" : "memory"; + this.collectionRoots.set(aliasName, { path: collection.path, kind: rootKind }); + } + log.info( + `qmd collection ${collection.name} remapped to existing collection ${aliasName} (same path)`, + ); + } else { + log.warn(`qmd collection add skipped for ${collection.name}: ${message}`); + } } continue; } log.warn(`qmd collection add failed for ${collection.name}: ${message}`); } } + // Recompute managed names after potential alias discoveries. + this.managedCollectionNames = this.computeManagedCollectionNames(); } private async listCollectionsBestEffort(): Promise> { @@ -366,6 +386,22 @@ export class QmdMemoryManager implements MemorySearchManager { return null; } + private findCollectionByPath( + target: ResolvedQmdCollection, + existing: Map, + ): string | undefined { + const normalizedPath = path.resolve(target.path); + for (const [name, details] of existing) { + if (name === target.name) { + continue; + } + if (details.path && path.resolve(details.path) === normalizedPath) { + return name; + } + } + return undefined; + } + private async tryRebindConflictingCollection(params: { collection: ManagedCollection; existing: Map; @@ -2037,12 +2073,16 @@ export class QmdMemoryManager implements MemorySearchManager { const seen = new Set(); const names: string[] = []; for (const collection of this.qmd.collections) { - const name = collection.name?.trim(); - if (!name || seen.has(name)) { + const configName = collection.name?.trim(); + if (!configName) { continue; } - seen.add(name); - names.push(name); + const resolved = this.collectionAliases.get(configName) ?? configName; + if (seen.has(resolved)) { + continue; + } + seen.add(resolved); + names.push(resolved); } return names; }