From c241f87fbe65858a42a518788ee5c4ddf2bbfe01 Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Tue, 1 Sep 2026 23:01:37 +0530 Subject: [PATCH 1/3] Add agent metadata column and store update API --- .changeset/20260901205645-agent-metadata.md | 5 + packages/trueforge/src/db/agentStore.ts | 9 ++ .../agent-store/PostgresAgentStore.ts | 22 +++++ .../20260901_000001_agent_metadata.ts | 18 ++++ packages/trueforge/src/db/postgres/types.ts | 3 + .../db/sqlite/agent-store/SqliteAgentStore.ts | 25 ++++- packages/trueforge/src/db/sqlite/client.ts | 1 + .../20260901_000001_agent_metadata.ts | 98 +++++++++++++++++++ packages/trueforge/src/db/sqlite/types.ts | 3 + .../trueforge/src/schemas/agentMetadata.ts | 11 +++ .../tests/db/agentStoreContractSuite.ts | 15 ++- 11 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 .changeset/20260901205645-agent-metadata.md create mode 100644 packages/trueforge/src/db/postgres/migrations/20260901_000001_agent_metadata.ts create mode 100644 packages/trueforge/src/db/sqlite/migrations/20260901_000001_agent_metadata.ts create mode 100644 packages/trueforge/src/schemas/agentMetadata.ts diff --git a/.changeset/20260901205645-agent-metadata.md b/.changeset/20260901205645-agent-metadata.md new file mode 100644 index 000000000..f74c75d2d --- /dev/null +++ b/.changeset/20260901205645-agent-metadata.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Add persisted `agent.metadata` on Postgres and SQLite with store-level `updateAgentMetadata`. diff --git a/packages/trueforge/src/db/agentStore.ts b/packages/trueforge/src/db/agentStore.ts index e30863dcc..6854033bc 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. */ @@ -42,6 +44,12 @@ export interface UpdateAgentInput { manifest: AgentSpec; } +export interface UpdateAgentMetadataInput { + tenant_id: string; + id: string; + metadata: AgentMetadata; +} + export interface DeleteAgentInput { tenant_id: string; id: string; @@ -67,6 +75,7 @@ export interface IAgentStore { createAgent(input: CreateAgentInput, transaction?: TTransaction): Promise; /** Replaces `manifest` for an existing id. Returns undefined if missing. */ updateAgent(input: UpdateAgentInput, transaction?: TTransaction): Promise; + updateAgentMetadata(input: UpdateAgentMetadataInput, 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..5e6fc468b 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, @@ -9,6 +10,7 @@ import { type GetAgentInput, type IAgentStore, type UpdateAgentInput, + type UpdateAgentMetadataInput, } from '../../agentStore'; import { isUniqueViolation } from '../client'; import { json, now } from '../sqlExpressions'; @@ -20,6 +22,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 +63,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(), }) @@ -89,6 +93,24 @@ export class PostgresAgentStore implements IAgentStore> { return row === undefined ? undefined : toRecord(row); } + async updateAgentMetadata( + input: UpdateAgentMetadataInput, + transaction?: Transaction, + ): Promise { + const db = transaction ?? this.#db; + const row = await db + .updateTable('agent') + .set({ + metadata: json(input.metadata), + updated_at: now(), + }) + .where('tenant_id', '=', input.tenant_id) + .where('id', '=', input.id) + .returningAll() + .executeTakeFirst(); + return row === undefined ? undefined : toRecord(row); + } + async deleteAgent(input: DeleteAgentInput, transaction?: Transaction): Promise { const db = transaction ?? this.#db; await db.deleteFrom('agent').where('tenant_id', '=', input.tenant_id).where('id', '=', input.id).execute(); 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..4561f748d 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, @@ -10,18 +11,20 @@ import { type GetAgentInput, type IAgentStore, type UpdateAgentInput, + type UpdateAgentMetadataInput, } from '../../agentStore'; 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 +35,7 @@ function toRecord(row: { tenant_id: string; name: AgentRecord['name']; manifest: AgentSpec; + metadata: AgentMetadata; created_at: string; updated_at: string; }): AgentRecord { @@ -79,6 +83,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, }) @@ -108,6 +113,24 @@ export class SqliteAgentStore implements IAgentStore> { return row === undefined ? undefined : toRecord(row); } + async updateAgentMetadata( + input: UpdateAgentMetadataInput, + transaction?: Transaction, + ): Promise { + const db = transaction ?? this.#db; + const row = await db + .updateTable('agent') + .set({ + metadata: jsonbBind(input.metadata), + updated_at: nowIso(), + }) + .where('tenant_id', '=', input.tenant_id) + .where('id', '=', input.id) + .returning(recordColumns) + .executeTakeFirst(); + return row === undefined ? undefined : toRecord(row); + } + async deleteAgent(input: DeleteAgentInput, transaction?: Transaction): Promise { const db = transaction ?? this.#db; await db.deleteFrom('agent').where('tenant_id', '=', input.tenant_id).where('id', '=', input.id).execute(); 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/agentMetadata.ts b/packages/trueforge/src/schemas/agentMetadata.ts new file mode 100644 index 000000000..29765d623 --- /dev/null +++ b/packages/trueforge/src/schemas/agentMetadata.ts @@ -0,0 +1,11 @@ +/** + * Agent-row metadata jsonb with a strict whitelist (empty until keys are added). + */ +import { z } from '@hono/zod-openapi'; + +/** `agent.metadata` jsonb; unknown keys are rejected until whitelisted. */ +export const AgentMetadataSchema = z.object({}).strict().openapi('AgentMetadata'); + +export type AgentMetadata = z.infer; + +export const EMPTY_AGENT_METADATA: AgentMetadata = {}; diff --git a/packages/trueforge/tests/db/agentStoreContractSuite.ts b/packages/trueforge/tests/db/agentStoreContractSuite.ts index 69093fab0..033da8825 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,17 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { expect(await store.getAgent({ tenant_id: TENANT, name: 'research' })).toEqual(updated); }); + it('updateAgentMetadata returns undefined for unknown ids', async () => { + const store = getStore(); + expect( + await store.updateAgentMetadata({ + tenant_id: TENANT, + id: 'missing', + metadata: {}, + }), + ).toBeUndefined(); + }); + it('updateAgent returns undefined for unknown ids', async () => { const store = getStore(); expect( From 1cdbee4d4a9f09fe9a5886cc5c439bd64f076c41 Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Wed, 2 Sep 2026 12:13:52 +0530 Subject: [PATCH 2/3] refactor: update agent metadata schema and add test for metadata update functionality --- .../trueforge/src/schemas/agentMetadata.ts | 10 ++---- .../tests/db/agentStoreContractSuite.ts | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/trueforge/src/schemas/agentMetadata.ts b/packages/trueforge/src/schemas/agentMetadata.ts index 29765d623..3b9f7e7cd 100644 --- a/packages/trueforge/src/schemas/agentMetadata.ts +++ b/packages/trueforge/src/schemas/agentMetadata.ts @@ -1,11 +1,7 @@ /** - * Agent-row metadata jsonb with a strict whitelist (empty until keys are added). + * Agent-row metadata jsonb (internal only; not on the public Agent API). + * Empty until keys are whitelisted here. */ -import { z } from '@hono/zod-openapi'; - -/** `agent.metadata` jsonb; unknown keys are rejected until whitelisted. */ -export const AgentMetadataSchema = z.object({}).strict().openapi('AgentMetadata'); - -export type AgentMetadata = z.infer; +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 033da8825..6bacd4074 100644 --- a/packages/trueforge/tests/db/agentStoreContractSuite.ts +++ b/packages/trueforge/tests/db/agentStoreContractSuite.ts @@ -80,6 +80,37 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { expect(await store.getAgent({ tenant_id: TENANT, name: 'research' })).toEqual(updated); }); + it('updateAgentMetadata replaces metadata but keeps id, name, manifest, and created_at', async () => { + const store = getStore(); + const created = await store.createAgent({ + tenant_id: TENANT, + name: 'research', + manifest: manifest(), + }); + + const updated = await store.updateAgentMetadata({ + 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 updateAgentMetadata 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('updateAgentMetadata returns undefined for unknown ids', async () => { const store = getStore(); expect( From f2d753cf15129b2b748e10f00a8a1f37f55eeed4 Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Wed, 2 Sep 2026 12:55:24 +0530 Subject: [PATCH 3/3] refactor: enhance agent update functionality to allow patching of manifest and metadata --- .changeset/20260901205645-agent-metadata.md | 2 +- packages/trueforge/src/db/agentStore.ts | 17 ++++++------- .../agent-store/PostgresAgentStore.ts | 25 ++++--------------- .../db/sqlite/agent-store/SqliteAgentStore.ts | 25 ++++--------------- packages/trueforge/src/schemas/agent.ts | 2 +- .../tests/db/agentStoreContractSuite.ts | 10 ++++---- .../trueforge/tests/unit/apis/agents.test.ts | 21 +++++++++++++++- 7 files changed, 44 insertions(+), 58 deletions(-) diff --git a/.changeset/20260901205645-agent-metadata.md b/.changeset/20260901205645-agent-metadata.md index f74c75d2d..3d54d10e5 100644 --- a/.changeset/20260901205645-agent-metadata.md +++ b/.changeset/20260901205645-agent-metadata.md @@ -2,4 +2,4 @@ "@truefoundry/trueforge": patch --- -Add persisted `agent.metadata` on Postgres and SQLite with store-level `updateAgentMetadata`. +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 6854033bc..b741e80ef 100644 --- a/packages/trueforge/src/db/agentStore.ts +++ b/packages/trueforge/src/db/agentStore.ts @@ -37,17 +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; -} - -export interface UpdateAgentMetadataInput { - tenant_id: string; - id: string; - metadata: AgentMetadata; + manifest?: AgentSpec; + metadata?: AgentMetadata; } export interface DeleteAgentInput { @@ -73,9 +71,8 @@ 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; - updateAgentMetadata(input: UpdateAgentMetadataInput, 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 5e6fc468b..0947caf25 100644 --- a/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts +++ b/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts @@ -10,7 +10,6 @@ import { type GetAgentInput, type IAgentStore, type UpdateAgentInput, - type UpdateAgentMetadataInput, } from '../../agentStore'; import { isUniqueViolation } from '../client'; import { json, now } from '../sqlExpressions'; @@ -79,29 +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), - updated_at: now(), - }) - .where('tenant_id', '=', input.tenant_id) - .where('id', '=', input.id) - .returningAll() - .executeTakeFirst(); - return row === undefined ? undefined : toRecord(row); - } - - async updateAgentMetadata( - input: UpdateAgentMetadataInput, - transaction?: Transaction, - ): Promise { - const db = transaction ?? this.#db; - const row = await db - .updateTable('agent') - .set({ - metadata: json(input.metadata), + ...(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/sqlite/agent-store/SqliteAgentStore.ts b/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts index 4561f748d..026f183ee 100644 --- a/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts +++ b/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts @@ -11,7 +11,6 @@ import { type GetAgentInput, type IAgentStore, type UpdateAgentInput, - type UpdateAgentMetadataInput, } from '../../agentStore'; import { isUniqueViolation } from '../client'; import { jsonbBind, jsonText, nowIso } from '../sqlExpressions'; @@ -99,29 +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), - updated_at: nowIso(), - }) - .where('tenant_id', '=', input.tenant_id) - .where('id', '=', input.id) - .returning(recordColumns) - .executeTakeFirst(); - return row === undefined ? undefined : toRecord(row); - } - - async updateAgentMetadata( - input: UpdateAgentMetadataInput, - transaction?: Transaction, - ): Promise { - const db = transaction ?? this.#db; - const row = await db - .updateTable('agent') - .set({ - metadata: jsonbBind(input.metadata), + ...(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/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/tests/db/agentStoreContractSuite.ts b/packages/trueforge/tests/db/agentStoreContractSuite.ts index 6bacd4074..65844b64b 100644 --- a/packages/trueforge/tests/db/agentStoreContractSuite.ts +++ b/packages/trueforge/tests/db/agentStoreContractSuite.ts @@ -80,7 +80,7 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { expect(await store.getAgent({ tenant_id: TENANT, name: 'research' })).toEqual(updated); }); - it('updateAgentMetadata replaces metadata but keeps id, name, manifest, and created_at', async () => { + it('updateAgent can patch metadata without changing manifest', async () => { const store = getStore(); const created = await store.createAgent({ tenant_id: TENANT, @@ -88,7 +88,7 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { manifest: manifest(), }); - const updated = await store.updateAgentMetadata({ + const updated = await store.updateAgent({ tenant_id: TENANT, id: created.id, metadata: {}, @@ -105,16 +105,16 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { ); expect(updated).toBeDefined(); if (updated === undefined) { - throw new Error('expected updateAgentMetadata to return a record'); + 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('updateAgentMetadata returns undefined for unknown ids', async () => { + it('updateAgent returns undefined for unknown ids when patching metadata', async () => { const store = getStore(); expect( - await store.updateAgentMetadata({ + await store.updateAgent({ tenant_id: TENANT, id: 'missing', metadata: {}, 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 () => {