fix(wiki): accept --agent for agent-scoped vaults (#117943)

* fix(wiki): accept --agent for agent-scoped vaults

* test(wiki): split agent-scope CLI coverage
This commit is contained in:
Peter Steinberger
2026-08-02 03:33:15 -07:00
committed by GitHub
parent edf434b1a3
commit 4d8fcf43a2
6 changed files with 280 additions and 33 deletions

View File

@@ -55,21 +55,22 @@ openclaw wiki obsidian daily
## Agent selection
When `plugins.entries.memory-wiki.config.vault.scope` is `agent`, select the
vault with the top-level `--agent <id>` option:
vault with the command's `--agent <id>` option:
```bash
openclaw wiki --agent support status
openclaw wiki --agent support search "refund policy"
openclaw wiki --agent marketing ingest ./campaign-notes.md
openclaw wiki status --agent support
openclaw wiki search "refund policy" --agent support
openclaw wiki ingest ./campaign-notes.md --agent marketing
```
In a setup with multiple configured agents, `--agent` is required for CLI
operations so a command cannot read or write an arbitrary default vault. If
only one agent is configured, that agent remains the default. Unknown agent ids
fail before the vault operation starts. The option does not change the selected
path when `vault.scope` is `global`.
When `--agent` is omitted, CLI operations use the configured default agent,
matching other agent-scoped CLI families. Pass the flag to select a different
agent. Unknown agent ids fail before the vault operation starts. If no default
can be resolved, the error tells you to pass `--agent <id>` or configure an
agent. The option does not change the selected path when `vault.scope` is
`global`.
Gateway clients follow the same rule: pass `agentId` on vault-backed `wiki.*`
Gateway clients remain explicit: pass `agentId` on vault-backed `wiki.*`
requests in an agent-scoped multi-agent setup. A missing or unknown id is an
error. Agent turns, wiki tools, memory corpus supplements, and compiled prompt
digests already carry the active runtime agent context.

View File

@@ -438,10 +438,9 @@ the existing `~/.openclaw/wiki/main` path.
Agent tools, compiled prompt digests, and the wiki supplement exposed through
`memory_search` / `memory_get` resolve the vault from the active agent context.
For CLI and Gateway calls in a setup with multiple configured agents, provide
the agent explicitly with `openclaw wiki --agent <agentId> ...` or the Gateway
request's `agentId`. A single configured agent remains the default when no id is
provided.
CLI calls use the configured default agent unless the command passes
`--agent <agentId>`. Gateway calls in a multi-agent setup still require the
request's `agentId`.
In bridge mode, agent-scoped imports accept a public memory artifact only when
its `agentIds` includes the selected agent. Artifacts owned by another agent,

View File

@@ -107,9 +107,9 @@ therefore keeps the existing `~/.openclaw/wiki/main` path. In global scope,
Wiki tools and compiled prompt/corpus supplements resolve the active runtime
agent on each call. In bridge mode, an agent vault imports only public memory
artifacts whose `agentIds` includes that agent; unowned and other-agent
artifacts are skipped. CLI and Gateway operations require an explicit agent in
multi-agent setups; use `openclaw wiki --agent <agentId> ...` or pass `agentId`
to the `wiki.*` RPC request. A single configured agent may remain implicit.
artifacts are skipped. CLI operations use the configured default agent unless
the command passes `--agent <agentId>`; Gateway operations in multi-agent
setups require `agentId` on the `wiki.*` RPC request.
Configuration validation rejects agent scope with either
`vaultMode: "unsafe-local"` or `obsidian.useOfficialCli: true`. Obsidian-friendly
@@ -182,8 +182,8 @@ openclaw wiki obsidian command workspace:quick-switcher
openclaw wiki obsidian daily
# Agent-scoped vault
openclaw wiki --agent support status
openclaw wiki --agent support search "refund policy"
openclaw wiki status --agent support
openclaw wiki search "refund policy" --agent support
```
## Agent tools

View File

@@ -0,0 +1,226 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { registerWikiCli } from "./cli.js";
import {
resolveMemoryWikiAgentConfig,
type MemoryWikiPluginConfig,
type ResolvedMemoryWikiConfig,
} from "./config.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const AGENT_SCOPED_WIKI_COMMANDS = [
{ label: "status", path: ["status"], args: ["status"] },
{ label: "doctor", path: ["doctor"], args: ["doctor"] },
{ label: "init", path: ["init"], args: ["init"] },
{ label: "compile", path: ["compile"], args: ["compile"] },
{ label: "lint", path: ["lint"], args: ["lint"] },
{ label: "ingest", path: ["ingest"], args: ["ingest", "note.md"] },
{ label: "okf import", path: ["okf", "import"], args: ["okf", "import", "bundle"] },
{ label: "search", path: ["search"], args: ["search", "query"] },
{ label: "get", path: ["get"], args: ["get", "entity.alpha"] },
{
label: "apply synthesis",
path: ["apply", "synthesis"],
args: ["apply", "synthesis", "Summary", "--body", "Body", "--source-id", "source.alpha"],
},
{
label: "apply metadata",
path: ["apply", "metadata"],
args: ["apply", "metadata", "entity.alpha"],
},
{ label: "bridge import", path: ["bridge", "import"], args: ["bridge", "import"] },
{
label: "chatgpt import",
path: ["chatgpt", "import"],
args: ["chatgpt", "import", "--export", "export"],
},
{
label: "chatgpt rollback",
path: ["chatgpt", "rollback"],
args: ["chatgpt", "rollback", "run-id"],
},
] as const;
const { createVault } = createMemoryWikiTestHarness();
let suiteRoot = "";
let caseIndex = 0;
let stdoutWriteMock: ReturnType<typeof vi.fn>;
describe("memory-wiki agent-scoped cli", () => {
beforeAll(async () => {
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-cli-agent-suite-"));
});
afterAll(async () => {
if (suiteRoot) {
await fs.rm(suiteRoot, { recursive: true, force: true });
}
});
beforeEach(() => {
stdoutWriteMock = vi.fn(() => true);
vi.spyOn(process.stdout, "write").mockImplementation(
stdoutWriteMock as unknown as typeof process.stdout.write,
);
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = undefined;
});
async function createCliVault(options?: { config?: MemoryWikiPluginConfig }) {
return createVault({
prefix: "memory-wiki-cli-agent-",
rootDir: path.join(suiteRoot, `case-${caseIndex++}`),
config: options?.config,
});
}
function stubWikiCommandAction(program: Command, commandPath: readonly string[]) {
let command = program.commands.find((candidate) => candidate.name() === "wiki");
for (const name of commandPath) {
command = command?.commands.find((candidate) => candidate.name() === name);
}
expect(command, `wiki ${commandPath.join(" ")} command`).toBeDefined();
command!.action(() => {});
return command!;
}
function createAgentSelectionProgram(params: {
config: ResolvedMemoryWikiConfig;
appConfig: { agents: { entries: Record<string, { default?: boolean }> } };
commandPath: readonly string[];
}) {
const resolveConfig = vi.fn((agentId?: string) =>
resolveMemoryWikiAgentConfig({
config: params.config,
appConfig: params.appConfig,
...(agentId ? { agentId } : {}),
}),
);
const program = new Command();
program.name("test").enablePositionalOptions().exitOverride();
program.configureOutput({ writeErr: () => {}, writeOut: () => {} });
registerWikiCli(program, {
config: params.config,
getAppConfig: () => params.appConfig,
resolveConfig,
});
const command = stubWikiCommandAction(program, params.commandPath);
return { command, program, resolveConfig };
}
it.each(AGENT_SCOPED_WIKI_COMMANDS)(
"accepts command-local --agent for wiki $label",
async ({ path: commandPath, args }) => {
const { rootDir, config } = await createCliVault({
config: { vault: { scope: "agent" } },
});
const appConfig = {
agents: { entries: { support: { default: true }, marketing: {} } },
};
const { command, program, resolveConfig } = createAgentSelectionProgram({
config,
appConfig,
commandPath,
});
await program.parseAsync(["wiki", ...args, "--agent", "marketing"], { from: "user" });
expect(command.helpInformation()).toContain("--agent <id>");
expect(resolveConfig).toHaveBeenCalledWith("marketing", appConfig);
expect(resolveConfig.mock.results[0]?.value.vault.path).toBe(path.join(rootDir, "marketing"));
},
);
it.each(AGENT_SCOPED_WIKI_COMMANDS)(
"uses the configured default agent for wiki $label",
async ({ path: commandPath, args }) => {
const { rootDir, config } = await createCliVault({
config: { vault: { scope: "agent" } },
});
const appConfig = {
agents: { entries: { support: { default: true }, marketing: {} } },
};
const { program, resolveConfig } = createAgentSelectionProgram({
config,
appConfig,
commandPath,
});
await program.parseAsync(["wiki", ...args], { from: "user" });
expect(resolveConfig).toHaveBeenCalledWith("support", appConfig);
expect(resolveConfig.mock.results[0]?.value.vault.path).toBe(path.join(rootDir, "support"));
},
);
it.each(AGENT_SCOPED_WIKI_COMMANDS)(
"gives actionable missing-agent guidance for wiki $label",
async ({ path: commandPath, args }) => {
const { config } = await createCliVault({
config: { vault: { scope: "agent" } },
});
const appConfig = { agents: { entries: {} } };
const { program, resolveConfig } = createAgentSelectionProgram({
config,
appConfig,
commandPath,
});
await expect(program.parseAsync(["wiki", ...args], { from: "user" })).rejects.toThrow(
"No default memory-wiki agent is configured. Pass --agent <id>, or add an agent with `openclaw agents add`.",
);
expect(resolveConfig).not.toHaveBeenCalled();
},
);
it("runs wiki doctor against explicit and default agent-scoped vaults", async () => {
const { rootDir, config } = await createCliVault({
config: { vault: { scope: "agent" } },
});
const appConfig = {
agents: { entries: { support: { default: true }, marketing: {} } },
};
const run = async (args: string[]) => {
stdoutWriteMock.mockClear();
const program = new Command();
program.name("test").enablePositionalOptions();
registerWikiCli(program, { config, getAppConfig: () => appConfig });
await program.parseAsync(["wiki", ...args], { from: "user" });
return stdoutWriteMock.mock.calls.map(([chunk]) => String(chunk)).join("");
};
await run(["init", "--agent", "marketing"]);
const explicitOutput = await run(["doctor", "--agent", "marketing"]);
await run(["init"]);
const defaultOutput = await run(["doctor"]);
expect(explicitOutput).toContain("Wiki doctor: healthy");
expect(explicitOutput).toContain("Vault scope: agent (marketing)");
expect(explicitOutput).toContain(path.join(rootDir, "marketing"));
expect(defaultOutput).toContain("Wiki doctor: healthy");
expect(defaultOutput).toContain("Vault scope: agent (support)");
expect(defaultOutput).toContain(path.join(rootDir, "support"));
});
it("does not require an agent to probe Obsidian CLI availability", async () => {
const { config } = await createCliVault({ config: { vault: { scope: "agent" } } });
const appConfig = { agents: { entries: {} } };
const { program, resolveConfig } = createAgentSelectionProgram({
config,
appConfig,
commandPath: ["obsidian", "status"],
});
await expect(
program.parseAsync(["wiki", "obsidian", "status"], { from: "user" }),
).resolves.toBeDefined();
expect(resolveConfig).not.toHaveBeenCalled();
});
});

View File

@@ -220,7 +220,7 @@ describe("memory-wiki cli", () => {
);
});
it("resolves --agent for local commands and requires it with multiple agent vaults", async () => {
it("keeps the parent --agent spelling compatible", async () => {
const { rootDir, config } = await createCliVault({
config: { vault: { scope: "agent" } },
});
@@ -237,14 +237,6 @@ describe("memory-wiki cli", () => {
});
await expect(fs.stat(path.join(rootDir, "marketing", "index.md"))).resolves.toBeDefined();
const missingAgentProgram = new Command();
missingAgentProgram.name("test");
missingAgentProgram.exitOverride();
registerWikiCli(missingAgentProgram, { config, getAppConfig: () => appConfig });
await expect(
missingAgentProgram.parseAsync(["wiki", "status", "--json"], { from: "user" }),
).rejects.toThrow("agentId is required for memory-wiki when vault.scope=agent.");
});
it("forwards --agent through every bridge Gateway call", async () => {

View File

@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import type { Command } from "commander";
import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/memory-host-core";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import {
isRecord,
@@ -985,11 +986,25 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
.command("wiki")
.description("Inspect and initialize the memory wiki vault")
.option("--agent <id>", "Agent id for agent-scoped wiki vaults");
wiki.hook("preAction", () => {
const requestedAgentId = wiki.opts<WikiCommandOptions>().agent?.trim() || undefined;
wiki.hook("preAction", (_thisCommand, actionCommand) => {
const needsAgent = actionCommand.options.some((option) => option.long === "--agent");
const requestedAgentId =
actionCommand.opts<WikiCommandOptions>().agent?.trim() ||
wiki.opts<WikiCommandOptions>().agent?.trim() ||
undefined;
const currentAppConfig = registration.getAppConfig?.();
const config = resolveConfig(requestedAgentId, currentAppConfig);
const agentId = config.agentId ?? requestedAgentId;
let agentId = requestedAgentId;
if (needsAgent && registration.config.vault.scope === "agent" && !agentId) {
try {
agentId = resolveDefaultAgentId(currentAppConfig ?? {});
} catch {
throw new Error(
"No default memory-wiki agent is configured. Pass --agent <id>, or add an agent with `openclaw agents add`.",
);
}
}
const config = needsAgent ? resolveConfig(agentId, currentAppConfig) : registration.config;
agentId = config.agentId ?? agentId;
commandContext = {
config,
...(currentAppConfig ? { appConfig: currentAppConfig } : {}),
@@ -1000,6 +1015,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
wiki
.command("status")
.description("Show wiki vault status")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Print JSON")
.action(async (opts: WikiStatusCommandOptions) => {
const { agentId, appConfig, config } = requireCommandContext();
@@ -1009,6 +1025,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
wiki
.command("doctor")
.description("Audit wiki vault setup and report actionable fixes")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Print JSON")
.action(async (opts: WikiDoctorCommandOptions) => {
const { agentId, appConfig, config } = requireCommandContext();
@@ -1018,6 +1035,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
wiki
.command("init")
.description("Initialize the wiki vault layout")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Print JSON")
.action(async (opts: WikiInitCommandOptions) => {
const { config } = requireCommandContext();
@@ -1027,6 +1045,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
wiki
.command("compile")
.description("Refresh generated wiki indexes")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Print JSON")
.action(async (opts: WikiCompileCommandOptions) => {
const { appConfig, config } = requireCommandContext();
@@ -1036,6 +1055,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
wiki
.command("lint")
.description("Lint the wiki vault and write a report")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Print JSON")
.action(async (opts: WikiLintCommandOptions) => {
const { appConfig, config } = requireCommandContext();
@@ -1046,6 +1066,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
.command("ingest")
.description("Ingest a local file into the wiki sources folder")
.argument("<path>", "Local file path to ingest")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--title <title>", "Override the source title")
.option("--json", "Print JSON")
.action(async (inputPath: string, opts: WikiIngestCommandOptions) => {
@@ -1058,6 +1079,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
.command("import")
.description("Import an unpacked OKF bundle into wiki concept pages")
.argument("<path>", "OKF bundle directory")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Print JSON")
.action(async (bundlePath: string, opts: WikiOkfImportCommandOptions) => {
const { config } = requireCommandContext();
@@ -1069,6 +1091,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
.command("search")
.description("Search wiki pages and, when configured, the active memory corpus")
.argument("<query>", "Search query")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--max-results <n>", "Maximum results", (value: string) =>
parseWikiPositiveIntegerOption(value, "--max-results"),
)
@@ -1095,6 +1118,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
.command("get")
.description("Read a wiki page by id or relative path, with optional active-memory fallback")
.argument("<lookup>", "Relative path or page id")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--from <n>", "Start line", (value: string) =>
parseWikiPositiveIntegerOption(value, "--from"),
)
@@ -1124,6 +1148,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
.command("synthesis")
.description("Create or refresh a synthesis page with managed summary content")
.argument("<title>", "Synthesis title")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--body <text>", "Summary body text")
.option("--body-file <path>", "Read summary body text from a file"),
)
@@ -1148,7 +1173,8 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
apply
.command("metadata")
.description("Update metadata on an existing page")
.argument("<lookup>", "Relative path or page id"),
.argument("<lookup>", "Relative path or page id")
.option("--agent <id>", "Agent id (default: configured default agent)"),
)
.option("--clear-confidence", "Remove any stored confidence value")
.option("--json", "Print JSON")
@@ -1174,6 +1200,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
bridge
.command("import")
.description("Sync bridge-backed memory artifacts into wiki source pages")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Print JSON")
.action(async (opts: WikiBridgeImportCommandOptions) => {
const { agentId, appConfig, config } = requireCommandContext();
@@ -1199,6 +1226,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
.command("import")
.description("Import a ChatGPT export into draft wiki source pages")
.requiredOption("--export <path>", "ChatGPT export directory or conversations.json path")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--dry-run", "Preview changes without writing", false)
.option("--json", "Print JSON")
.action(async (opts: WikiChatGptImportCommandOptions) => {
@@ -1214,6 +1242,7 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
.command("rollback")
.description("Roll back a previously applied ChatGPT import run")
.argument("<run-id>", "Import run id")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Print JSON")
.action(async (runId: string, opts: WikiChatGptRollbackCommandOptions) => {
const { config } = requireCommandContext();