diff --git a/.changeset/20260901205645-agent-metadata.md b/.changeset/20260901205645-agent-metadata.md new file mode 100644 index 000000000..3d54d10e5 --- /dev/null +++ b/.changeset/20260901205645-agent-metadata.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Add persisted `agent.metadata` on Postgres and SQLite; store `updateAgent` can patch manifest and/or metadata. diff --git a/packages/trueforge/src/db/agentStore.ts b/packages/trueforge/src/db/agentStore.ts index e30863dcc..b741e80ef 100644 --- a/packages/trueforge/src/db/agentStore.ts +++ b/packages/trueforge/src/db/agentStore.ts @@ -5,6 +5,7 @@ * Implementations: PostgresAgentStore and SqliteAgentStore. */ import { AgentSpecSchema, type AgentSpec } from '@truefoundry/trueforge-core/agent-session'; +import type { AgentMetadata } from '../schemas/agentMetadata'; import type { ResourceName } from '../schemas/common'; export interface AgentRecord { @@ -12,6 +13,7 @@ export interface AgentRecord { tenant_id: string; name: ResourceName; manifest: AgentSpec; + metadata: AgentMetadata; /** ISO-8601 UTC instant. */ created_at: string; /** ISO-8601 UTC instant. */ @@ -35,11 +37,15 @@ export interface CreateAgentInput { manifest: AgentSpec; } -/** Replace manifest for an existing agent keyed by immutable id. */ +/** + * Patch an existing agent by immutable id. At least one of `manifest` or `metadata` is required. + * Provided fields replace the stored column; omitted fields are left unchanged. + */ export interface UpdateAgentInput { tenant_id: string; id: string; - manifest: AgentSpec; + manifest?: AgentSpec; + metadata?: AgentMetadata; } export interface DeleteAgentInput { @@ -65,7 +71,7 @@ export interface IAgentStore { getAgent(input: GetAgentInput, transaction?: TTransaction): Promise; /** Inserts a new agent with a generated ULID. Throws AgentNameConflictError on name clash. */ createAgent(input: CreateAgentInput, transaction?: TTransaction): Promise; - /** Replaces `manifest` for an existing id. Returns undefined if missing. */ + /** Patches `manifest` and/or `metadata` for an existing id. Returns undefined if missing. */ updateAgent(input: UpdateAgentInput, transaction?: TTransaction): Promise; /** Deletes by immutable id. Idempotent if already missing. */ deleteAgent(input: DeleteAgentInput, transaction?: TTransaction): Promise; diff --git a/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts b/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts index c72c6e904..0947caf25 100644 --- a/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts +++ b/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts @@ -1,4 +1,5 @@ import type { Kysely, Selectable, Transaction } from 'kysely'; +import { EMPTY_AGENT_METADATA } from '../../../schemas/agentMetadata'; import { newId } from '../../../utils/id'; import { AgentNameConflictError, @@ -20,6 +21,7 @@ function toRecord(row: Selectable): AgentRecord { tenant_id: row.tenant_id, name: row.name, manifest: parseStoredAgentSpec(row.manifest), + metadata: row.metadata, created_at: row.created_at.toISOString(), updated_at: row.updated_at.toISOString(), }; @@ -60,6 +62,7 @@ export class PostgresAgentStore implements IAgentStore> { tenant_id: input.tenant_id, name: input.name, manifest: json(input.manifest), + metadata: json(EMPTY_AGENT_METADATA), created_at: now(), updated_at: now(), }) @@ -75,11 +78,15 @@ export class PostgresAgentStore implements IAgentStore> { } async updateAgent(input: UpdateAgentInput, transaction?: Transaction): Promise { + if (input.manifest === undefined && input.metadata === undefined) { + throw new Error('updateAgent requires manifest and/or metadata'); + } const db = transaction ?? this.#db; const row = await db .updateTable('agent') .set({ - manifest: json(input.manifest), + ...(input.manifest === undefined ? {} : { manifest: json(input.manifest) }), + ...(input.metadata === undefined ? {} : { metadata: json(input.metadata) }), updated_at: now(), }) .where('tenant_id', '=', input.tenant_id) diff --git a/packages/trueforge/src/db/postgres/migrations/20260901_000001_agent_metadata.ts b/packages/trueforge/src/db/postgres/migrations/20260901_000001_agent_metadata.ts new file mode 100644 index 000000000..4e39bc17b --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20260901_000001_agent_metadata.ts @@ -0,0 +1,18 @@ +import { sql, type Kysely } from 'kysely'; + +/** + * Agent registry metadata jsonb column. + * Default `{}` for existing rows. + */ +export async function up(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql` + ALTER TABLE agent + ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql`ALTER TABLE agent DROP COLUMN IF EXISTS metadata`.execute(db); +} diff --git a/packages/trueforge/src/db/postgres/types.ts b/packages/trueforge/src/db/postgres/types.ts index 3a84fea23..240f68acb 100644 --- a/packages/trueforge/src/db/postgres/types.ts +++ b/packages/trueforge/src/db/postgres/types.ts @@ -20,6 +20,7 @@ import type { } from '@truefoundry/trueforge-core/core'; import type { CurrentContextUsage } from '@truefoundry/trueforge-core/core/runtime/contextUsage'; import type { ColumnType, Generated, JSONColumnType } from 'kysely'; +import type { AgentMetadata } from '../../schemas/agentMetadata'; import type { McpServerManifest } from '../../schemas/mcpServer'; import type { ModelProviderManifest } from '../../schemas/modelProvider'; import type { SandboxBuildMetadata, SandboxBuildStatus, SandboxProviderManifest } from '../../schemas/sandboxProvider'; @@ -372,6 +373,8 @@ export interface AgentTable { name: string; /** AgentSpec document; replaced whole on every upsert */ manifest: JSONColumnType; + /** `agent.metadata` jsonb; default `{}` for existing rows */ + metadata: JSONColumnType; created_at: Date; updated_at: Date; } diff --git a/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts b/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts index 46143af88..026f183ee 100644 --- a/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts +++ b/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts @@ -1,5 +1,6 @@ import type { AgentSpec } from '@truefoundry/trueforge-core/agent-session'; import type { ExpressionBuilder, Kysely, Transaction } from 'kysely'; +import { EMPTY_AGENT_METADATA, type AgentMetadata } from '../../../schemas/agentMetadata'; import { newId } from '../../../utils/id'; import { AgentNameConflictError, @@ -15,13 +16,14 @@ import { isUniqueViolation } from '../client'; import { jsonbBind, jsonText, nowIso } from '../sqlExpressions'; import type { Database } from '../types'; -/** Column list projecting the JSONB manifest as parsed JSON (see JSON_RESULT_COLUMNS). */ +/** Column list projecting JSONB columns as parsed JSON (see JSON_RESULT_COLUMNS). */ function recordColumns(eb: ExpressionBuilder) { return [ 'id' as const, 'tenant_id' as const, 'name' as const, jsonText(eb.ref('manifest')).as('manifest'), + jsonText(eb.ref('metadata')).as('metadata'), 'created_at' as const, 'updated_at' as const, ]; @@ -32,6 +34,7 @@ function toRecord(row: { tenant_id: string; name: AgentRecord['name']; manifest: AgentSpec; + metadata: AgentMetadata; created_at: string; updated_at: string; }): AgentRecord { @@ -79,6 +82,7 @@ export class SqliteAgentStore implements IAgentStore> { tenant_id: input.tenant_id, name: input.name, manifest: jsonbBind(input.manifest), + metadata: jsonbBind(EMPTY_AGENT_METADATA), created_at: timestamp, updated_at: timestamp, }) @@ -94,11 +98,15 @@ export class SqliteAgentStore implements IAgentStore> { } async updateAgent(input: UpdateAgentInput, transaction?: Transaction): Promise { + if (input.manifest === undefined && input.metadata === undefined) { + throw new Error('updateAgent requires manifest and/or metadata'); + } const db = transaction ?? this.#db; const row = await db .updateTable('agent') .set({ - manifest: jsonbBind(input.manifest), + ...(input.manifest === undefined ? {} : { manifest: jsonbBind(input.manifest) }), + ...(input.metadata === undefined ? {} : { metadata: jsonbBind(input.metadata) }), updated_at: nowIso(), }) .where('tenant_id', '=', input.tenant_id) diff --git a/packages/trueforge/src/db/sqlite/client.ts b/packages/trueforge/src/db/sqlite/client.ts index ffa1aaee2..038907679 100644 --- a/packages/trueforge/src/db/sqlite/client.ts +++ b/packages/trueforge/src/db/sqlite/client.ts @@ -138,6 +138,7 @@ const JSON_RESULT_COLUMNS = new Set([ 'thread_checkpoint', 'event', 'manifest', + 'metadata', 'build_metadata', 'oauth_server', 'oauth_client', diff --git a/packages/trueforge/src/db/sqlite/migrations/20260901_000001_agent_metadata.ts b/packages/trueforge/src/db/sqlite/migrations/20260901_000001_agent_metadata.ts new file mode 100644 index 000000000..70235b781 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20260901_000001_agent_metadata.ts @@ -0,0 +1,98 @@ +import { sql, type Kysely } from 'kysely'; + +/** + * Agent registry metadata jsonb column. Mirrors Postgres. + * Rebuild: ADD/DROP COLUMN cannot take DEFAULT (jsonb(...)) / STRICT drop. + * DROP TABLE agent needs FKs off (`schedule` REFERENCES it). + * PRAGMA foreign_keys is a no-op inside a txn. + */ +export async function up(db: Kysely): Promise { + await sql`PRAGMA foreign_keys = OFF`.execute(db); + try { + await db.transaction().execute(async trx => { + await sql` + CREATE TABLE agent_new ( + id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + manifest BLOB NOT NULL, + metadata BLOB NOT NULL DEFAULT (jsonb('{}')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (id), + UNIQUE (tenant_id, name) + ) STRICT + `.execute(trx); + + await sql` + INSERT INTO agent_new ( + id, + tenant_id, + name, + manifest, + metadata, + created_at, + updated_at + ) + SELECT + id, + tenant_id, + name, + manifest, + jsonb('{}'), + created_at, + updated_at + FROM agent + `.execute(trx); + + await sql`DROP TABLE agent`.execute(trx); + await sql`ALTER TABLE agent_new RENAME TO agent`.execute(trx); + }); + } finally { + await sql`PRAGMA foreign_keys = ON`.execute(db); + } +} + +export async function down(db: Kysely): Promise { + await sql`PRAGMA foreign_keys = OFF`.execute(db); + try { + await db.transaction().execute(async trx => { + await sql` + CREATE TABLE agent_old ( + id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + manifest BLOB NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (id), + UNIQUE (tenant_id, name) + ) STRICT + `.execute(trx); + + await sql` + INSERT INTO agent_old ( + id, + tenant_id, + name, + manifest, + created_at, + updated_at + ) + SELECT + id, + tenant_id, + name, + manifest, + created_at, + updated_at + FROM agent + `.execute(trx); + + await sql`DROP TABLE agent`.execute(trx); + await sql`ALTER TABLE agent_old RENAME TO agent`.execute(trx); + }); + } finally { + await sql`PRAGMA foreign_keys = ON`.execute(db); + } +} diff --git a/packages/trueforge/src/db/sqlite/types.ts b/packages/trueforge/src/db/sqlite/types.ts index c6869a4ce..4dc2d5468 100644 --- a/packages/trueforge/src/db/sqlite/types.ts +++ b/packages/trueforge/src/db/sqlite/types.ts @@ -23,6 +23,7 @@ import type { } from '@truefoundry/trueforge-core/core'; import type { CurrentContextUsage } from '@truefoundry/trueforge-core/core/runtime/contextUsage'; import type { ColumnType, Generated, JSONColumnType } from 'kysely'; +import type { AgentMetadata } from '../../schemas/agentMetadata'; import type { McpServerManifest } from '../../schemas/mcpServer'; import type { ModelProviderManifest } from '../../schemas/modelProvider'; import type { SandboxBuildMetadata, SandboxBuildStatus, SandboxProviderManifest } from '../../schemas/sandboxProvider'; @@ -219,6 +220,8 @@ export interface AgentTable { name: string; /** AgentSpec document; replaced whole on every upsert */ manifest: JsonbColumn; + /** `agent.metadata` jsonb; default `{}` for existing rows */ + metadata: JsonbColumn; created_at: string; updated_at: string; } diff --git a/packages/trueforge/src/schemas/agent.ts b/packages/trueforge/src/schemas/agent.ts index b6db2b7d6..dd14dac44 100644 --- a/packages/trueforge/src/schemas/agent.ts +++ b/packages/trueforge/src/schemas/agent.ts @@ -15,7 +15,7 @@ export const CreateAgentRequestSchema = z .strict() .openapi('CreateAgentRequest'); -/** PUT body: full manifest replacement. Identity is the path `agent_id`. */ +/** PUT body: full manifest replacement only (metadata is store-internal, not on the wire). */ export const UpdateAgentRequestSchema = z .object({ manifest: AgentSpecSchema, diff --git a/packages/trueforge/src/schemas/agentMetadata.ts b/packages/trueforge/src/schemas/agentMetadata.ts new file mode 100644 index 000000000..3b9f7e7cd --- /dev/null +++ b/packages/trueforge/src/schemas/agentMetadata.ts @@ -0,0 +1,7 @@ +/** + * Agent-row metadata jsonb (internal only; not on the public Agent API). + * Empty until keys are whitelisted here. + */ +export type AgentMetadata = Record; + +export const EMPTY_AGENT_METADATA: AgentMetadata = {}; diff --git a/packages/trueforge/tests/db/agentStoreContractSuite.ts b/packages/trueforge/tests/db/agentStoreContractSuite.ts index 69093fab0..65844b64b 100644 --- a/packages/trueforge/tests/db/agentStoreContractSuite.ts +++ b/packages/trueforge/tests/db/agentStoreContractSuite.ts @@ -30,6 +30,7 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { expect(created.name).toBe('research'); expect(created.id.length).toBeGreaterThan(0); expect(created.manifest).toEqual(manifest()); + expect(created.metadata).toEqual({}); expect(created.created_at).toMatch(ISO_UTC); expect(created.updated_at).toBe(created.created_at); @@ -46,7 +47,7 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { expect(await store.getAgent({ tenant_id: TENANT, name: 'missing' })).toBeUndefined(); }); - it('updateAgent by id replaces manifest but keeps id, name, and created_at', async () => { + it('updateAgent by id replaces manifest but keeps id, name, metadata, and created_at', async () => { const store = getStore(); const created = await store.createAgent({ tenant_id: TENANT, @@ -66,6 +67,7 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { id: created.id, name: 'research', manifest: replacement, + metadata: {}, created_at: created.created_at, }), ); @@ -78,6 +80,48 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { expect(await store.getAgent({ tenant_id: TENANT, name: 'research' })).toEqual(updated); }); + it('updateAgent can patch metadata without changing manifest', async () => { + const store = getStore(); + const created = await store.createAgent({ + tenant_id: TENANT, + name: 'research', + manifest: manifest(), + }); + + const updated = await store.updateAgent({ + tenant_id: TENANT, + id: created.id, + metadata: {}, + }); + + expect(updated).toEqual( + expect.objectContaining({ + id: created.id, + name: 'research', + manifest: created.manifest, + metadata: {}, + created_at: created.created_at, + }), + ); + expect(updated).toBeDefined(); + if (updated === undefined) { + throw new Error('expected updateAgent to return a record'); + } + expect(Date.parse(updated.updated_at)).toBeGreaterThanOrEqual(Date.parse(created.updated_at)); + expect(await store.getAgent({ tenant_id: TENANT, id: created.id })).toEqual(updated); + }); + + it('updateAgent returns undefined for unknown ids when patching metadata', async () => { + const store = getStore(); + expect( + await store.updateAgent({ + tenant_id: TENANT, + id: 'missing', + metadata: {}, + }), + ).toBeUndefined(); + }); + it('updateAgent returns undefined for unknown ids', async () => { const store = getStore(); expect( diff --git a/packages/trueforge/tests/unit/apis/agents.test.ts b/packages/trueforge/tests/unit/apis/agents.test.ts index da5e48d39..2f81b5d4c 100644 --- a/packages/trueforge/tests/unit/apis/agents.test.ts +++ b/packages/trueforge/tests/unit/apis/agents.test.ts @@ -69,14 +69,16 @@ function jsonInit(method: string, body: unknown): RequestInit { describe('agents router', () => { let router: ReturnType; + let agentStore: SqliteAgentStore; beforeAll(async () => { const db = createSqliteDb(':memory:'); await migrateSqliteToLatest(db); const modelProviderStore = new SqliteModelProviderStore(db); await modelProviderStore.upsertProvider({ tenant_id: 'default', name: 'anthropic', manifest: modelProvider }); + agentStore = new SqliteAgentStore(db); router = createAgentsRouter({ - agentStore: new SqliteAgentStore(db), + agentStore, resolveModelProviderStore: () => modelProviderStore, mcpServerStore: new SqliteMcpServerStore(db), skillStore: new SqliteSkillStore(db), @@ -105,6 +107,10 @@ describe('agents router', () => { }, }, }); + expect(createdJson.data).not.toHaveProperty('metadata'); + + const beforePut = await agentStore.getAgent({ tenant_id: 'default', id: createdJson.data.id }); + expect(beforePut?.metadata).toEqual({}); const updated = await router.request(`/${createdJson.data.id}`, jsonInit('PUT', updateBody)); expect(updated.status).toBe(200); @@ -112,6 +118,19 @@ describe('agents router', () => { expect(updatedJson.data.id).toBe(createdJson.data.id); expect(updatedJson.data.name).toBe('research'); expect(updatedJson.data.manifest.instructions).toBe('Updated instructions.'); + expect(updatedJson.data).not.toHaveProperty('metadata'); + + const afterPut = await agentStore.getAgent({ tenant_id: 'default', id: createdJson.data.id }); + expect(afterPut?.metadata).toEqual(beforePut?.metadata); + }); + + it('PUT rejects metadata in the request body', async () => { + const created = await router.request('/', jsonInit('POST', { ...writeBody, name: 'no-meta' })); + expect(created.status).toBe(201); + const createdJson = (await created.json()) as { data: WireAgent }; + + const put = await router.request(`/${createdJson.data.id}`, jsonInit('PUT', { ...updateBody, metadata: {} })); + expect(put.status).toBe(400); }); it('GET and PUT return 404 for unknown ids', async () => {