mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
edd9234e54
commit
0e280f2d33
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -35,6 +35,9 @@ export type AgentSummary = {
|
||||
bindingDetails?: string[];
|
||||
routes?: string[];
|
||||
providers?: string[];
|
||||
createdVia?: "operator" | "agent" | "claw";
|
||||
creatorAgentId?: string | null;
|
||||
createdAt?: number;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}.`);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user