feat(agents): record agent creation provenance and add roster tree listing (#124828)

Adds an additive agent_provenance table (shared state DB, schema v8) owned
by src/state/agent-provenance.ts. createAgent() records operator/agent
provenance after commit, the system-agent create-agent operation passes its
own id as creator, and Claw installs record created-via claw at their roster
commit point. Agent deletion removes the agent's own row inside the deletion
journal transaction; children keep dangling creator ids as historical fact.
openclaw agents list gains --tree (provenance hierarchy) and JSON provenance
fields.
This commit is contained in:
Peter Steinberger
2026-08-16 16:25:44 -07:00
committed by GitHub
parent edd9234e54
commit 0e280f2d33
26 changed files with 469 additions and 28 deletions
+14
View File
@@ -82,6 +82,20 @@ Add `bindings` to route inbound messages (the wizard offers to do this for you),
openclaw agents list --bindings
```
### Agent provenance
OpenClaw records how each configured agent was created: `operator` for CLI,
onboarding, and Gateway requests; `agent` when the system agent requested it;
and `claw` when a Claw install added it. Agent-created entries also retain the
requesting agent id. Inspect the current creation hierarchy with:
```bash
openclaw agents list --tree
```
Deleted creators remain historical provenance. If the creator is no longer in
the configured roster, its children appear at the root of the tree.
## Quick start
<Steps>
@@ -9,6 +9,7 @@ import { readExactSessionEntryRowForCanonicalRepair } from "../config/sessions/s
import { writeSessionEntry } from "../config/sessions/session-accessor.sqlite-entry-store.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { readAgentProvenance } from "../state/agent-provenance.js";
import { writeConfigMachineState } from "../state/config-machine-state.js";
import {
closeOpenClawAgentDatabasesForTest,
@@ -55,6 +56,38 @@ it("keeps a fresh named workspace pending through the first run setup", async ()
}
});
it("records operator and agent creation provenance after roster commits", async () => {
const state = await createOpenClawTestState({
layout: "state-only",
scenario: "empty",
label: "agent-creation-provenance",
});
try {
await createAgent({ name: "Operator Child", workspace: state.path("operator-child") });
await createAgent({
name: "Agent Child",
workspace: state.path("agent-child"),
provenance: { createdVia: "agent", creatorAgentId: "main" },
});
expect(readAgentProvenance("operator-child", { env: state.env })).toMatchObject({
agentId: "operator-child",
createdVia: "operator",
creatorAgentId: null,
createdAtMs: expect.any(Number),
});
expect(readAgentProvenance("agent-child", { env: state.env })).toMatchObject({
agentId: "agent-child",
createdVia: "agent",
creatorAgentId: "main",
createdAtMs: expect.any(Number),
});
} finally {
closeOpenClawStateDatabaseForTest();
await state.cleanup();
}
});
describe("agent roster persistence", () => {
async function addWorkerToConfig(config: unknown): Promise<OpenClawConfig> {
const state = await createOpenClawTestState({
+8
View File
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
rootRead: vi.fn(),
rootWrite: vi.fn(),
mkdir: vi.fn(),
recordAgentProvenance: vi.fn(),
readAgentDeletionJournal: vi.fn(() => undefined as Record<string, unknown> | undefined),
claimCompletedAgentDeletion: vi.fn(() => true),
migrateLegacyMainSessionKeys: vi.fn(),
@@ -49,6 +50,10 @@ vi.mock("../state/agent-deletion-journal.js", () => ({
readAgentDeletionJournal: mocks.readAgentDeletionJournal,
}));
vi.mock("../state/agent-provenance.js", () => ({
recordAgentProvenance: mocks.recordAgentProvenance,
}));
vi.mock("../config/sessions/legacy-main-session-migration.js", () => ({
migrateLegacyMainSessionKeys: mocks.migrateLegacyMainSessionKeys,
}));
@@ -261,6 +266,9 @@ describe("createAgent", () => {
workspace: "/tmp/default-researcher",
bootstrapPending: true,
});
expect(mocks.recordAgentProvenance).toHaveBeenCalledWith("researcher", {
createdVia: "operator",
});
});
it("accepts a complete staged entry", async () => {
+5
View File
@@ -20,6 +20,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { FsSafeError, root } from "../infra/fs-safe.js";
import { normalizeAgentId, normalizeAgentIdStrict } from "../routing/session-key.js";
import { readAgentDeletionJournal } from "../state/agent-deletion-journal.js";
import { recordAgentProvenance, type AgentCreatedVia } from "../state/agent-provenance.js";
import { isReservedSystemAgentId } from "../system-agent/agent-id.js";
import { resolveUserPath } from "../utils.js";
import { claimCompletedAgentDeletion } from "./agent-lifecycle-registry.js";
@@ -86,6 +87,7 @@ type CreateAgentParams = {
skipOptionalBootstrapFiles?: OptionalBootstrapFileName[];
bindingSpecs?: string[];
transformConfig?: typeof transformConfigFileWithRetry;
provenance?: { createdVia: AgentCreatedVia; creatorAgentId?: string };
};
class DuplicateAgentError extends Error {}
@@ -459,6 +461,9 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
throw new Error(`agent "${agentId}" deletion tombstone changed during creation`);
}
const result = committed.result!;
if (result.status === "created") {
recordAgentProvenance(agentId, params.provenance ?? { createdVia: "operator" });
}
return typeof committed.persistedHash === "string"
? { ...result, configHash: committed.persistedHash }
: result;
+6
View File
@@ -12,6 +12,7 @@ import { resolvePathViaExistingAncestorSync } from "../infra/boundary-path.js";
import { normalizeWindowsPathForComparison } from "../infra/path-guards.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { recordAgentProvenance } from "../state/agent-provenance.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import { resolveUserPath } from "../utils.js";
import {
@@ -484,6 +485,11 @@ export async function applyClawAddPlan(
configCommitted = true;
return nextConfig;
});
try {
recordAgentProvenance(plan.agent.finalId, { createdVia: "claw" }, options);
} catch (error) {
throw new ClawAddMutationError("provenance_failed", coerceErrorMessage(error));
}
if (options.resumePlan && installRecord.schemaVersion === "openclaw.clawInstallRecord.v1") {
installRecord = persistRecord(plan, {
...options,
+7
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { readAgentProvenance } from "../state/agent-provenance.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { applyClawAddPlan, ClawAddMutationError } from "./add.js";
import { ClawCronInstallError } from "./cron.js";
@@ -918,6 +919,12 @@ describe("applyClawAddPlan", () => {
});
expect(config.agents?.entries?.worker).toBeDefined();
expect(readInstallRow("worker", root)?.status).toBe("complete");
expect(readAgentProvenance("worker", { env: stateEnv(root) })).toMatchObject({
agentId: "worker",
createdVia: "claw",
creatorAgentId: null,
createdAtMs: expect.any(Number),
});
});
it("recreates a missing workspace for a matching workspace-ready record", async () => {
+2 -2
View File
@@ -283,8 +283,8 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
},
{
commandPath: ["agents", "list"],
// Text and JSON output are derived from config plus read-only channel
// metadata, so the route should not preload bundled plugin runtimes.
// Output combines config with shared-state provenance and optional read-only
// channel metadata, so the route should not preload bundled plugin runtimes.
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
route: { id: "agents-list" },
},
+2 -1
View File
@@ -311,11 +311,12 @@ describe("agent command registration", () => {
});
it("forwards agents list options", async () => {
await runCli(["agents", "list", "--json", "--bindings"]);
await runCli(["agents", "list", "--json", "--bindings", "--tree"]);
expect(agentsListCommandMock).toHaveBeenCalledWith(
{
json: true,
bindings: true,
tree: true,
},
runtime,
);
+6 -1
View File
@@ -85,11 +85,16 @@ export function registerAgentsCommands(program: Command): void {
.description("List configured agents")
.option("--json", "Output JSON instead of text", false)
.option("--bindings", "Include routing bindings", false)
.option("--tree", "Render agent creation hierarchy", false)
.action(async (opts): Promise<void> => {
await runAgentsCommandAction(async (runtime) => {
const agentsListCommand = await loadAgentsListCommand();
await agentsListCommand(
{ json: Boolean(opts.json), bindings: Boolean(opts.bindings) },
{
json: Boolean(opts.json),
bindings: Boolean(opts.bindings),
tree: Boolean(opts.tree),
},
runtime,
);
});
+13 -3
View File
@@ -182,10 +182,10 @@ describe("route-args", () => {
"list",
"--json",
]),
).toEqual({ json: true, bindings: false });
).toEqual({ json: true, bindings: false, tree: false });
expect(
parseAgentsListRouteArgs(["node", "openclaw", "agents", "--json", "--bindings"]),
).toEqual({ json: true, bindings: true });
).toEqual({ json: true, bindings: true, tree: false });
});
it("parses gateway status route args and rejects probe-only ssh flags", () => {
@@ -332,14 +332,24 @@ describe("route-args", () => {
expect(parseSessionsRouteArgs(["node", "openclaw", "sessions", "--agent"])).toBeNull();
expect(parseSessionsRouteArgs(["node", "openclaw", "sessions", "--limit"])).toBeNull();
expect(
parseAgentsListRouteArgs(["node", "openclaw", "agents", "list", "--json", "--bindings"]),
parseAgentsListRouteArgs([
"node",
"openclaw",
"agents",
"list",
"--json",
"--bindings",
"--tree",
]),
).toEqual({
json: true,
bindings: true,
tree: true,
});
expect(parseAgentsListRouteArgs(["node", "openclaw", "agents"])).toEqual({
json: false,
bindings: false,
tree: false,
});
});
+8 -3
View File
@@ -276,20 +276,25 @@ export function parseSessionsRouteArgs(argv: string[]) {
export function parseAgentsListRouteArgs(argv: string[]) {
const listPositionals = getRoutedCommandPositionals(argv, {
commandPath: ["agents", "list"],
booleanFlags: ["--json", "--bindings"],
booleanFlags: ["--json", "--bindings", "--tree"],
});
if (listPositionals && listPositionals.length === 0) {
return {
json: hasFlag(argv, "--json"),
bindings: hasFlag(argv, "--bindings"),
tree: hasFlag(argv, "--tree"),
};
}
const aliasPositionals = getRoutedCommandPositionals(argv, {
commandPath: ["agents"],
booleanFlags: ["--json", "--bindings"],
booleanFlags: ["--json", "--bindings", "--tree"],
});
return aliasPositionals?.length === 0
? { json: hasFlag(argv, "--json"), bindings: hasFlag(argv, "--bindings") }
? {
json: hasFlag(argv, "--json"),
bindings: hasFlag(argv, "--bindings"),
tree: hasFlag(argv, "--tree"),
}
: null;
}
+3 -3
View File
@@ -122,15 +122,15 @@ describe("program routes", () => {
it("passes parsed agents list flags through", async () => {
await expect(expectRoute(["agents"]).run(routeArgv("agents"))).resolves.toBe(true);
expect(agentsListCommandMock).toHaveBeenCalledWith(
{ json: false, bindings: false },
{ json: false, bindings: false, tree: false },
defaultRuntime,
);
await expect(
expectRoute(["agents", "list"]).run(routeArgv("agents list --json --bindings")),
expectRoute(["agents", "list"]).run(routeArgv("agents list --json --bindings --tree")),
).resolves.toBe(true);
expect(agentsListCommandMock).toHaveBeenLastCalledWith(
{ json: true, bindings: true },
{ json: true, bindings: true, tree: true },
defaultRuntime,
);
});
+58 -1
View File
@@ -11,6 +11,8 @@ const {
buildProviderStatusIndexMock,
buildProviderSummaryMetadataIndexMock,
listProvidersForAgentMock,
listAgentProvenanceMock,
readAgentProvenanceMock,
providerSummaryMetadataMock,
requireValidConfigMock,
summarizeBindingsMock,
@@ -18,6 +20,8 @@ const {
buildProviderStatusIndexMock: vi.fn(),
buildProviderSummaryMetadataIndexMock: vi.fn(),
listProvidersForAgentMock: vi.fn(),
listAgentProvenanceMock: vi.fn(),
readAgentProvenanceMock: vi.fn(),
providerSummaryMetadataMock: new Map([
[
"telegram",
@@ -43,6 +47,11 @@ vi.mock("./agents.providers.js", () => ({
summarizeBindings: summarizeBindingsMock,
}));
vi.mock("../state/agent-provenance.js", () => ({
listAgentProvenance: listAgentProvenanceMock,
readAgentProvenance: readAgentProvenanceMock,
}));
const { agentsListCommand } = await import("./agents.commands.list.js");
function createRuntime(): OutputRuntimeEnv & { json: unknown[] } {
@@ -75,21 +84,66 @@ describe("agentsListCommand", () => {
buildProviderStatusIndexMock.mockResolvedValue(new Map());
buildProviderSummaryMetadataIndexMock.mockReturnValue(providerSummaryMetadataMock);
listProvidersForAgentMock.mockReturnValue(["Telegram default: configured"]);
listAgentProvenanceMock.mockReturnValue([]);
readAgentProvenanceMock.mockReturnValue(undefined);
summarizeBindingsMock.mockReturnValue(["Telegram default"]);
});
it("keeps plain JSON output on the config-only path", async () => {
it("adds durable provenance to JSON without loading provider details", async () => {
const runtime = createRuntime();
readAgentProvenanceMock.mockReturnValue({
agentId: "main",
createdVia: "operator",
creatorAgentId: null,
createdAtMs: 42,
});
await agentsListCommand({ json: true }, runtime);
expect(buildProviderStatusIndexMock).not.toHaveBeenCalled();
const summary = (runtime.json[0] as Array<Record<string, unknown>>)[0];
expect(summary?.id).toBe("main");
expect(summary).toMatchObject({
createdVia: "operator",
creatorAgentId: null,
createdAt: 42,
});
expect(summary).not.toHaveProperty("routes");
expect(summary).not.toHaveProperty("providers");
});
it("renders roots, children, missing rows, and dangling creators as a tree", async () => {
requireValidConfigMock.mockResolvedValueOnce({
agents: {
entries: {
main: { name: "Main" },
child: { name: "Child" },
legacy: { name: "Legacy" },
orphan: { name: "Orphan" },
},
},
} satisfies OpenClawConfig);
listAgentProvenanceMock.mockReturnValue([
{ agentId: "main", createdVia: "operator", creatorAgentId: null, createdAtMs: 1 },
{ agentId: "child", createdVia: "agent", creatorAgentId: "main", createdAtMs: 2 },
{ agentId: "orphan", createdVia: "agent", creatorAgentId: "deleted", createdAtMs: 3 },
]);
const runtime = createRuntime();
await agentsListCommand({ tree: true }, runtime);
expect(vi.mocked(runtime.log)).toHaveBeenCalledWith(
[
"Agents:",
"- main (Main)",
" - child (Child)",
"- legacy (Legacy)",
"- orphan (Orphan)",
].join("\n"),
);
expect(buildProviderStatusIndexMock).not.toHaveBeenCalled();
});
it("keeps provider details available for JSON callers that request bindings", async () => {
const runtime = createRuntime();
const cfg = createConfig();
@@ -117,6 +171,9 @@ describe("agentsListCommand", () => {
expect(summary?.id).toBe("main");
expect(summary?.routes).toEqual(["Telegram default"]);
expect(summary?.providers).toEqual(["Telegram default: configured"]);
expect(summary).not.toHaveProperty("createdVia");
expect(summary).not.toHaveProperty("creatorAgentId");
expect(summary).not.toHaveProperty("createdAt");
});
it("keeps human output enriched from read-only provider metadata", async () => {
+71 -10
View File
@@ -5,6 +5,11 @@ import { listRouteBindings } from "../config/bindings.js";
import type { AgentRouteBinding } from "../config/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { type RuntimeEnv, writeRuntimeJson, defaultRuntime } from "../runtime.js";
import {
listAgentProvenance,
readAgentProvenance,
type AgentProvenance,
} from "../state/agent-provenance.js";
import { shortenHomePath } from "../utils.js";
import { describeBinding } from "./agents.bindings.js";
import type { AgentSummary } from "./agents.config.js";
@@ -20,15 +25,20 @@ import { requireValidConfig } from "./config-validation.js";
type AgentsListOptions = {
json?: boolean;
bindings?: boolean;
tree?: boolean;
};
function formatSummaryHeader(summary: AgentSummary): string {
const safe = sanitizeTerminalText;
const defaultTag = summary.isDefault ? " (default)" : "";
return summary.name && summary.name !== summary.id
? `${safe(summary.id)}${defaultTag} (${safe(summary.name)})`
: `${safe(summary.id)}${defaultTag}`;
}
function formatSummary(summary: AgentSummary) {
const safe = sanitizeTerminalText;
const defaultTag = summary.isDefault ? " (default)" : "";
const header =
summary.name && summary.name !== summary.id
? `${safe(summary.id)}${defaultTag} (${safe(summary.name)})`
: `${safe(summary.id)}${defaultTag}`;
const header = formatSummaryHeader(summary);
const identityParts = [];
if (summary.identityEmoji) {
@@ -75,6 +85,42 @@ function formatSummary(summary: AgentSummary) {
return lines.join("\n");
}
function formatAgentTree(summaries: AgentSummary[], provenance: AgentProvenance[]): string[] {
const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
const provenanceById = new Map(provenance.map((record) => [record.agentId, record]));
const childrenById = new Map<string, AgentSummary[]>();
const roots: AgentSummary[] = [];
for (const summary of summaries) {
const creatorAgentId = provenanceById.get(summary.id)?.creatorAgentId;
if (creatorAgentId && creatorAgentId !== summary.id && summaryById.has(creatorAgentId)) {
const children = childrenById.get(creatorAgentId) ?? [];
children.push(summary);
childrenById.set(creatorAgentId, children);
} else {
roots.push(summary);
}
}
const lines: string[] = [];
const visited = new Set<string>();
const append = (summary: AgentSummary, depth: number): void => {
if (visited.has(summary.id)) {
return;
}
visited.add(summary.id);
lines.push(`${" ".repeat(depth)}- ${formatSummaryHeader(summary)}`);
for (const child of childrenById.get(summary.id) ?? []) {
append(child, depth + 1);
}
};
roots.forEach((summary) => append(summary, 0));
// Corrupt or manually rewritten provenance can form a cycle. Keep every
// configured agent visible by promoting the first unseen member to a root.
summaries.forEach((summary) => append(summary, 0));
return lines;
}
/** Print configured agent summaries with optional binding/provider detail enrichment. */
export async function agentsListCommand(
opts: AgentsListOptions,
@@ -86,6 +132,17 @@ export async function agentsListCommand(
}
const summaries = buildAgentSummaries(cfg);
const provenance = opts.tree ? listAgentProvenance() : [];
if (opts.json) {
for (const summary of summaries) {
const record = readAgentProvenance(summary.id);
if (record) {
summary.createdVia = record.createdVia;
summary.creatorAgentId = record.creatorAgentId;
summary.createdAt = record.createdAtMs;
}
}
}
const bindingMap = new Map<string, AgentRouteBinding[]>();
for (const binding of listRouteBindings(cfg)) {
const agentId = normalizeAgentId(binding.agentId);
@@ -105,11 +162,10 @@ export async function agentsListCommand(
// Provider details are only used for human text output
// (`summary.providers` is rendered in the text formatter). JSON callers
// (dashboards, monitors, IDE plugins) poll the config-derived fields, so skip
// the provider detail pass unless they explicitly ask for binding/provider
// enrichment with --bindings. Combined with `loadPlugins: "text-only"` in the
// catalog entry, this keeps `agents list --json` on the config-only path.
const includeProviderDetails = !opts.json || opts.bindings === true;
// (dashboards, monitors, IDE plugins) poll the config/state-derived fields, so
// skip the provider detail pass unless they explicitly ask for enrichment.
// This keeps JSON and tree output off the bundled plugin runtime path.
const includeProviderDetails = (!opts.json && !opts.tree) || opts.bindings === true;
const providerStatus = includeProviderDetails ? await buildProviderStatusIndex(cfg) : null;
const providerMetadata = includeProviderDetails ? buildProviderSummaryMetadataIndex(cfg) : null;
@@ -141,6 +197,11 @@ export async function agentsListCommand(
return;
}
if (opts.tree) {
runtime.log(["Agents:", ...formatAgentTree(summaries, provenance)].join("\n"));
return;
}
const lines = ["Agents:", ...summaries.map(formatSummary)];
lines.push("Routing rules map channel/account/peer to an agent. Use --bindings for full rules.");
lines.push(
+3
View File
@@ -35,6 +35,9 @@ export type AgentSummary = {
bindingDetails?: string[];
routes?: string[];
providers?: string[];
createdVia?: "operator" | "agent" | "claw";
creatorAgentId?: string | null;
createdAt?: number;
isDefault: boolean;
};
+5
View File
@@ -20,6 +20,7 @@ import {
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { readAgentDeletionJournal } from "../state/agent-deletion-journal.js";
import { readAgentProvenance, recordAgentProvenance } from "../state/agent-provenance.js";
import { writeConfigMachineState } from "../state/config-machine-state.js";
import {
listOpenClawRegisteredAgentDatabases,
@@ -508,6 +509,8 @@ describe("agents delete command", () => {
await arrangeAgentsDeleteTest({ stateDir, cfg, sessions: {} });
const databasePath = path.join(stateDir, "agents", "ops", "agent", "openclaw-agent.sqlite");
registerOpenClawAgentDatabase({ agentId: "ops", path: databasePath });
recordAgentProvenance("ops", { createdVia: "operator" });
recordAgentProvenance("child", { createdVia: "agent", creatorAgentId: "ops" });
expect(listOpenClawRegisteredAgentDatabases().map((entry) => entry.agentId)).toContain("ops");
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
@@ -516,6 +519,8 @@ describe("agents delete command", () => {
"ops",
);
expect(readAgentDeletionJournal("ops")?.cleanupCompleted).toBe(true);
expect(readAgentProvenance("ops")).toBeUndefined();
expect(readAgentProvenance("child")).toMatchObject({ creatorAgentId: "ops" });
});
});
+4
View File
@@ -9,6 +9,7 @@ import {
import { isPathInside } from "../infra/path-guards.js";
import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { deleteAgentProvenanceForAgent, ensureAgentProvenanceSchema } from "./agent-provenance.js";
import type {
OpenClawStateDatabase,
OpenClawStateDatabaseOptions,
@@ -369,6 +370,7 @@ export function beginAgentDeletionJournal(
cleanupPaths: entry.cleanupPaths ?? [],
};
let persisted: AgentDeletionJournalEntry | undefined;
ensureAgentProvenanceSchema(options);
runOpenClawStateWriteTransaction((database) => {
ensureAgentDeletionJournalSchema(database.db);
const db = getNodeSqliteKysely<AgentDeletionDatabase>(database.db);
@@ -419,6 +421,7 @@ export function beginAgentDeletionJournal(
cleanupCompleted: false,
deleteFiles: normalized.deleteFiles,
};
deleteAgentProvenanceForAgent(database.db, normalized.agentId);
return;
}
const createdAt = Date.now();
@@ -438,6 +441,7 @@ export function beginAgentDeletionJournal(
}),
);
persisted = { ...normalized, databasePaths, cleanupPaths, createdAt, cleanupCompleted: false };
deleteAgentProvenanceForAgent(database.db, normalized.agentId);
}, options);
if (!persisted) {
throw new Error(`Failed to record deletion journal for agent ${normalized.agentId}.`);
+42
View File
@@ -0,0 +1,42 @@
import { expect, it } from "vitest";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import {
deleteAgentProvenanceForAgent,
listAgentProvenance,
readAgentProvenance,
recordAgentProvenance,
} from "./agent-provenance.js";
import { openOpenClawStateDatabase } from "./openclaw-state-db.js";
it("records, replaces, lists, and deletes agent creation provenance", async () => {
await withOpenClawTestState(
{ layout: "state-only", scenario: "empty", label: "agent-provenance" },
async (state) => {
recordAgentProvenance("Worker", { createdVia: "operator" }, { env: state.env, nowMs: 10 });
expect(readAgentProvenance("worker", { env: state.env })).toEqual({
agentId: "worker",
createdVia: "operator",
creatorAgentId: null,
createdAtMs: 10,
});
recordAgentProvenance(
"worker",
{ createdVia: "agent", creatorAgentId: "Main" },
{ env: state.env, nowMs: 20 },
);
expect(listAgentProvenance({ env: state.env })).toEqual([
{
agentId: "worker",
createdVia: "agent",
creatorAgentId: "main",
createdAtMs: 20,
},
]);
const database = openOpenClawStateDatabase({ env: state.env });
deleteAgentProvenanceForAgent(database.db, "worker");
expect(readAgentProvenance("worker", { env: state.env })).toBeUndefined();
},
);
});
+146
View File
@@ -0,0 +1,146 @@
import type { DatabaseSync } from "node:sqlite";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "./openclaw-state-db.js";
export type AgentCreatedVia = "operator" | "agent" | "claw";
export type AgentProvenance = {
agentId: string;
createdVia: AgentCreatedVia;
creatorAgentId: string | null;
createdAtMs: number;
};
type AgentProvenanceDatabase = Pick<OpenClawStateKyselyDatabase, "agent_provenance">;
type AgentProvenanceOptions = OpenClawStateDatabaseOptions & { nowMs?: number };
const ensuredDatabases = new WeakSet<DatabaseSync>();
const AGENT_PROVENANCE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS agent_provenance (
agent_id TEXT PRIMARY KEY,
created_via TEXT NOT NULL CHECK (created_via IN ('operator', 'agent', 'claw')),
creator_agent_id TEXT,
created_at_ms INTEGER NOT NULL
) STRICT;
`;
export function ensureAgentProvenanceSchema(options: OpenClawStateDatabaseOptions = {}): void {
const database = openOpenClawStateDatabase(options);
if (ensuredDatabases.has(database.db)) {
return;
}
runOpenClawStateWriteTransaction(
({ db }) => {
// sqlite-allow-raw -- feature-local additive schema DDL; provenance rows use Kysely.
db.exec(AGENT_PROVENANCE_SCHEMA_SQL);
},
options,
{ operationLabel: "agent-provenance.schema.ensure" },
);
ensuredDatabases.add(database.db);
}
function fromRow(row: {
agent_id: string;
created_via: string;
creator_agent_id: string | null;
created_at_ms: number;
}): AgentProvenance {
let createdVia: AgentCreatedVia;
switch (row.created_via) {
case "operator":
case "agent":
case "claw":
createdVia = row.created_via;
break;
default:
throw new Error(`Invalid agent provenance created_via: ${row.created_via}`);
}
return {
agentId: row.agent_id,
createdVia,
creatorAgentId: row.creator_agent_id,
createdAtMs: row.created_at_ms,
};
}
export function recordAgentProvenance(
agentId: string,
provenance: { createdVia: AgentCreatedVia; creatorAgentId?: string },
options: AgentProvenanceOptions = {},
): void {
ensureAgentProvenanceSchema(options);
const id = normalizeAgentId(agentId);
const creatorAgentId = provenance.creatorAgentId
? normalizeAgentId(provenance.creatorAgentId)
: null;
const createdAtMs = options.nowMs ?? Date.now();
runOpenClawStateWriteTransaction(
({ db: sqlite }) => {
const db = getNodeSqliteKysely<AgentProvenanceDatabase>(sqlite);
executeSqliteQuerySync(
sqlite,
db
.insertInto("agent_provenance")
.values({
agent_id: id,
created_via: provenance.createdVia,
creator_agent_id: creatorAgentId,
created_at_ms: createdAtMs,
})
.onConflict((conflict) =>
conflict.column("agent_id").doUpdateSet({
created_via: provenance.createdVia,
creator_agent_id: creatorAgentId,
created_at_ms: createdAtMs,
}),
),
);
},
options,
{ operationLabel: "agent-provenance.record" },
);
}
export function readAgentProvenance(
agentId: string,
options: OpenClawStateDatabaseOptions = {},
): AgentProvenance | undefined {
ensureAgentProvenanceSchema(options);
const database = openOpenClawStateDatabase(options);
const db = getNodeSqliteKysely<AgentProvenanceDatabase>(database.db);
const row = executeSqliteQueryTakeFirstSync(
database.db,
db.selectFrom("agent_provenance").selectAll().where("agent_id", "=", normalizeAgentId(agentId)),
);
return row ? fromRow(row) : undefined;
}
export function listAgentProvenance(options: OpenClawStateDatabaseOptions = {}): AgentProvenance[] {
ensureAgentProvenanceSchema(options);
const database = openOpenClawStateDatabase(options);
const db = getNodeSqliteKysely<AgentProvenanceDatabase>(database.db);
return executeSqliteQuerySync(
database.db,
db.selectFrom("agent_provenance").selectAll().orderBy("agent_id", "asc"),
).rows.map(fromRow);
}
/** Delete one row inside the caller's authoritative state transaction. */
export function deleteAgentProvenanceForAgent(database: DatabaseSync, agentId: string): void {
const db = getNodeSqliteKysely<AgentProvenanceDatabase>(database);
executeSqliteQuerySync(
database,
db.deleteFrom("agent_provenance").where("agent_id", "=", normalizeAgentId(agentId)),
);
}
+1
View File
@@ -28,6 +28,7 @@ export const FIRST_USE_STATE_INDEXES = [
// lazy ensures run; fold them into the next natural schema-version bump.
export const LAZY_ADDITIVE_STATE_TABLES = [
...FIRST_USE_STATE_TABLES,
"agent_provenance",
"cron_run_receipts",
"cron_store_epochs",
"model_catalog_remote",
+8
View File
@@ -84,6 +84,13 @@ export interface AgentModelCatalogs {
updated_at: number;
}
export interface AgentProvenance {
agent_id: string;
created_at_ms: number;
created_via: string;
creator_agent_id: string | null;
}
export interface AndroidNotificationRecentPackages {
package_name: string;
sort_order: number;
@@ -1713,6 +1720,7 @@ export interface DB {
agent_databases: AgentDatabases;
agent_deletion_journal: AgentDeletionJournal;
agent_model_catalogs: AgentModelCatalogs;
agent_provenance: AgentProvenance;
android_notification_recent_packages: AndroidNotificationRecentPackages;
apns_registration_tombstones: ApnsRegistrationTombstones;
apns_registrations: ApnsRegistrations;
+7
View File
@@ -1214,6 +1214,13 @@ CREATE TABLE IF NOT EXISTS agent_deletion_journal (
delete_files INTEGER NOT NULL DEFAULT 1
) STRICT;
CREATE TABLE IF NOT EXISTS agent_provenance (
agent_id TEXT PRIMARY KEY,
created_via TEXT NOT NULL CHECK (created_via IN ('operator', 'agent', 'claw')),
creator_agent_id TEXT,
created_at_ms INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS agent_database_leases (
lease_id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
@@ -231,7 +231,10 @@ describe("SystemAgentChatEngine approval", () => {
const reply = await engine.handle("yes");
expect(createAgent).toHaveBeenCalledWith({ name: "researcher" });
expect(createAgent).toHaveBeenCalledWith({
name: "researcher",
provenance: { createdVia: "agent", creatorAgentId: "openclaw" },
});
expect(reply.action).toBe("open-tui");
expect(reply.handoff).toMatchObject({
kind: "open-tui",
+2 -1
View File
@@ -4,7 +4,7 @@ import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-k
import type { RuntimeEnv } from "../runtime.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { t } from "../wizard/i18n/index.js";
import { isReservedSystemAgentId } from "./agent-id.js";
import { isReservedSystemAgentId, SYSTEM_AGENT_ID } from "./agent-id.js";
import { SYSTEM_AGENT_AUDIT_STORE_LABEL } from "./audit.js";
import { redactSystemAgentConfig } from "./config-redaction.js";
import {
@@ -413,6 +413,7 @@ export async function executeSystemAgentOperation(
return await createAgentForOperation({
name: operation.agentId,
...(operation.workspace ? { workspace: operation.workspace } : {}),
provenance: { createdVia: "agent", creatorAgentId: SYSTEM_AGENT_ID },
});
});
if (result.status === "error") {
+5 -1
View File
@@ -503,7 +503,11 @@ describe("system agent operations", () => {
),
).rejects.toThrow("Run openclaw doctor --fix before creating main.");
expect(createAgent).toHaveBeenCalledWith({ name: "main", workspace: "/tmp/main" });
expect(createAgent).toHaveBeenCalledWith({
name: "main",
workspace: "/tmp/main",
provenance: { createdVia: "agent", creatorAgentId: "openclaw" },
});
});
it("keeps the retired agent identity reserved", async () => {
+6 -1
View File
@@ -682,11 +682,16 @@ describe("OpenClaw rescue message", () => {
expect(deps.createAgent).toHaveBeenCalledTimes(1);
const [agentParams] = requireFirstMockCall(deps.createAgent, "agents add") as unknown as [
{ name: string; workspace: string },
{
name: string;
workspace: string;
provenance: { createdVia: string; creatorAgentId: string };
},
];
expect(agentParams).toEqual({
name: "work",
workspace: "/tmp/work",
provenance: { createdVia: "agent", creatorAgentId: "openclaw" },
});
const audit = readLastAuditEntry() as {
operation?: string;