Merge 3719718e9ac12e82c9b3afc0b7f61c308f027faa into 598f1826d8b2bc969aace2c6459824737667218c
This commit is contained in:
commit
dbde64f336
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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" } },
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user