diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 1a746fb5b17a..6c1ce5bf65f1 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -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 diff --git a/src/agents/agent-create.integration.test.ts b/src/agents/agent-create.integration.test.ts index 2bbaee61c02a..87c1468a380d 100644 --- a/src/agents/agent-create.integration.test.ts +++ b/src/agents/agent-create.integration.test.ts @@ -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 { const state = await createOpenClawTestState({ diff --git a/src/agents/agent-create.test.ts b/src/agents/agent-create.test.ts index 4a856acbd3ef..4198db98bb4d 100644 --- a/src/agents/agent-create.test.ts +++ b/src/agents/agent-create.test.ts @@ -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 | 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 () => { diff --git a/src/agents/agent-create.ts b/src/agents/agent-create.ts index 40e12525d6aa..49d87206d57d 100644 --- a/src/agents/agent-create.ts +++ b/src/agents/agent-create.ts @@ -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 { }); 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 () => { diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 6a3c0e4ae2a5..a106f4c7a0a9 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -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" }, }, diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index b61df4f31ac8..791427ca4be9 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -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, ); diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 74665fe620db..0b1dd6700336 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -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 => { 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, ); }); diff --git a/src/cli/program/route-args.test.ts b/src/cli/program/route-args.test.ts index bf4cc632300c..03d3be06bd51 100644 --- a/src/cli/program/route-args.test.ts +++ b/src/cli/program/route-args.test.ts @@ -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, }); }); diff --git a/src/cli/program/route-args.ts b/src/cli/program/route-args.ts index 9e235b5a351d..573ce1a6a07a 100644 --- a/src/cli/program/route-args.ts +++ b/src/cli/program/route-args.ts @@ -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; } diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 2fc3089e0c51..5b1b2e29027e 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -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, ); }); diff --git a/src/commands/agents.commands.list.test.ts b/src/commands/agents.commands.list.test.ts index 92098a6f5096..4f056a18e55d 100644 --- a/src/commands/agents.commands.list.test.ts +++ b/src/commands/agents.commands.list.test.ts @@ -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>)[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 () => { diff --git a/src/commands/agents.commands.list.ts b/src/commands/agents.commands.list.ts index 73b1f4cdb1bd..34891f406573 100644 --- a/src/commands/agents.commands.list.ts +++ b/src/commands/agents.commands.list.ts @@ -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(); + 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(); + 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(); 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( diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts index 117714680864..43b4e10b812c 100644 --- a/src/commands/agents.config.ts +++ b/src/commands/agents.config.ts @@ -35,6 +35,9 @@ export type AgentSummary = { bindingDetails?: string[]; routes?: string[]; providers?: string[]; + createdVia?: "operator" | "agent" | "claw"; + creatorAgentId?: string | null; + createdAt?: number; isDefault: boolean; }; diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index 5c9bcb248858..735c665c2c69 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -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" }); }); }); diff --git a/src/state/agent-deletion-journal.ts b/src/state/agent-deletion-journal.ts index 0b9e69c0e70f..6120ce2bc1c3 100644 --- a/src/state/agent-deletion-journal.ts +++ b/src/state/agent-deletion-journal.ts @@ -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(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}.`); diff --git a/src/state/agent-provenance.test.ts b/src/state/agent-provenance.test.ts new file mode 100644 index 000000000000..bbe34e71543e --- /dev/null +++ b/src/state/agent-provenance.test.ts @@ -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(); + }, + ); +}); diff --git a/src/state/agent-provenance.ts b/src/state/agent-provenance.ts new file mode 100644 index 000000000000..9391ec100c37 --- /dev/null +++ b/src/state/agent-provenance.ts @@ -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; +type AgentProvenanceOptions = OpenClawStateDatabaseOptions & { nowMs?: number }; + +const ensuredDatabases = new WeakSet(); +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(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(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(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(database); + executeSqliteQuerySync( + database, + db.deleteFrom("agent_provenance").where("agent_id", "=", normalizeAgentId(agentId)), + ); +} diff --git a/src/state/openclaw-state-db-contract.ts b/src/state/openclaw-state-db-contract.ts index 4226e9856eea..db04f48459c5 100644 --- a/src/state/openclaw-state-db-contract.ts +++ b/src/state/openclaw-state-db-contract.ts @@ -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", diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index c01590b73995..0dd52be41f62 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -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; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 72884ffbabc8..6c6f4c2b14c1 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -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, diff --git a/src/system-agent/chat-turn-router.approval.test.ts b/src/system-agent/chat-turn-router.approval.test.ts index ab11a546b1d1..2c622c94dcc1 100644 --- a/src/system-agent/chat-turn-router.approval.test.ts +++ b/src/system-agent/chat-turn-router.approval.test.ts @@ -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", diff --git a/src/system-agent/operations-execute.ts b/src/system-agent/operations-execute.ts index 154716b1a07c..b5e2cd85f2d2 100644 --- a/src/system-agent/operations-execute.ts +++ b/src/system-agent/operations-execute.ts @@ -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") { diff --git a/src/system-agent/operations.test.ts b/src/system-agent/operations.test.ts index c00e5d390586..d77ac000e17c 100644 --- a/src/system-agent/operations.test.ts +++ b/src/system-agent/operations.test.ts @@ -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 () => { diff --git a/src/system-agent/rescue-message.test.ts b/src/system-agent/rescue-message.test.ts index 5a6b09d21b40..ca8ef2407e41 100644 --- a/src/system-agent/rescue-message.test.ts +++ b/src/system-agent/rescue-message.test.ts @@ -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;