Merge 3719718e9ac12e82c9b3afc0b7f61c308f027faa into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
Hudson 2026-03-20 21:25:15 -07:00 committed by GitHub
commit dbde64f336
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 348 additions and 7 deletions

View File

@ -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:

View File

@ -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.

View File

@ -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: {

View File

@ -788,6 +788,8 @@ export const FIELD_HELP: Record<string, string> = {
'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":

View File

@ -319,6 +319,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@ -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). */

View File

@ -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(),

View File

@ -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" } },

View File

@ -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<string>,
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<string>();
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";

View File

@ -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<string, unknown> }).collectionRoots;
expect(roots.has("sessions-family")).toBe(true);
await manager.close();
});
it("migrates unscoped legacy collections before adding scoped names", async () => {
cfg = {
...cfg,

View File

@ -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<string, CollectionRoot>();
private readonly collectionAliases = new Map<string, string>();
private readonly sources = new Set<MemorySource>();
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<Map<string, ListedCollection>> {
@ -366,6 +386,22 @@ export class QmdMemoryManager implements MemorySearchManager {
return null;
}
private findCollectionByPath(
target: ResolvedQmdCollection,
existing: Map<string, ListedCollection>,
): 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<string, ListedCollection>;
@ -2037,12 +2073,16 @@ export class QmdMemoryManager implements MemorySearchManager {
const seen = new Set<string>();
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;
}