Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/20260901205645-agent-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge": patch
---

Add persisted `agent.metadata` on Postgres and SQLite; store `updateAgent` can patch manifest and/or metadata.
12 changes: 9 additions & 3 deletions packages/trueforge/src/db/agentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
* 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 {
id: string;
tenant_id: string;
name: ResourceName;
manifest: AgentSpec;
metadata: AgentMetadata;
/** ISO-8601 UTC instant. */
created_at: string;
/** ISO-8601 UTC instant. */
Expand All @@ -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 {
Expand All @@ -65,7 +71,7 @@ export interface IAgentStore<TTransaction = never> {
getAgent(input: GetAgentInput, transaction?: TTransaction): Promise<AgentRecord | undefined>;
/** Inserts a new agent with a generated ULID. Throws AgentNameConflictError on name clash. */
createAgent(input: CreateAgentInput, transaction?: TTransaction): Promise<AgentRecord>;
/** 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<AgentRecord | undefined>;
/** Deletes by immutable id. Idempotent if already missing. */
deleteAgent(input: DeleteAgentInput, transaction?: TTransaction): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,6 +21,7 @@ function toRecord(row: Selectable<AgentTable>): 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(),
};
Expand Down Expand Up @@ -60,6 +62,7 @@ export class PostgresAgentStore implements IAgentStore<Transaction<Database>> {
tenant_id: input.tenant_id,
name: input.name,
manifest: json(input.manifest),
metadata: json(EMPTY_AGENT_METADATA),
created_at: now(),
updated_at: now(),
})
Expand All @@ -75,11 +78,15 @@ export class PostgresAgentStore implements IAgentStore<Transaction<Database>> {
}

async updateAgent(input: UpdateAgentInput, transaction?: Transaction<Database>): Promise<AgentRecord | undefined> {
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<void> {
await sql`SET LOCAL lock_timeout = '5s'`.execute(db);
await sql`
ALTER TABLE agent
ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb
Comment thread
heerambavi1998 marked this conversation as resolved.
`.execute(db);
}

export async function down(db: Kysely<unknown>): Promise<void> {
await sql`SET LOCAL lock_timeout = '5s'`.execute(db);
await sql`ALTER TABLE agent DROP COLUMN IF EXISTS metadata`.execute(db);
}
3 changes: 3 additions & 0 deletions packages/trueforge/src/db/postgres/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -372,6 +373,8 @@ export interface AgentTable {
name: string;
/** AgentSpec document; replaced whole on every upsert */
manifest: JSONColumnType<AgentSpec, AgentSpec, AgentSpec>;
/** `agent.metadata` jsonb; default `{}` for existing rows */
metadata: JSONColumnType<AgentMetadata, AgentMetadata, AgentMetadata>;
created_at: Date;
updated_at: Date;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<Database, 'agent'>) {
return [
'id' as const,
'tenant_id' as const,
'name' as const,
jsonText<AgentSpec>(eb.ref('manifest')).as('manifest'),
jsonText<AgentMetadata>(eb.ref('metadata')).as('metadata'),
'created_at' as const,
'updated_at' as const,
];
Expand All @@ -32,6 +34,7 @@ function toRecord(row: {
tenant_id: string;
name: AgentRecord['name'];
manifest: AgentSpec;
metadata: AgentMetadata;
created_at: string;
updated_at: string;
}): AgentRecord {
Expand Down Expand Up @@ -79,6 +82,7 @@ export class SqliteAgentStore implements IAgentStore<Transaction<Database>> {
tenant_id: input.tenant_id,
name: input.name,
manifest: jsonbBind(input.manifest),
metadata: jsonbBind(EMPTY_AGENT_METADATA),
Comment thread
heerambavi1998 marked this conversation as resolved.
created_at: timestamp,
updated_at: timestamp,
})
Expand All @@ -94,11 +98,15 @@ export class SqliteAgentStore implements IAgentStore<Transaction<Database>> {
}

async updateAgent(input: UpdateAgentInput, transaction?: Transaction<Database>): Promise<AgentRecord | undefined> {
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)
Expand Down
1 change: 1 addition & 0 deletions packages/trueforge/src/db/sqlite/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ const JSON_RESULT_COLUMNS = new Set([
'thread_checkpoint',
'event',
'manifest',
'metadata',
'build_metadata',
'oauth_server',
'oauth_client',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<void> {
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<unknown>): Promise<void> {
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);
}
}
3 changes: 3 additions & 0 deletions packages/trueforge/src/db/sqlite/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -219,6 +220,8 @@ export interface AgentTable {
name: string;
/** AgentSpec document; replaced whole on every upsert */
manifest: JsonbColumn<AgentSpec>;
/** `agent.metadata` jsonb; default `{}` for existing rows */
metadata: JsonbColumn<AgentMetadata>;
created_at: string;
updated_at: string;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/trueforge/src/schemas/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/trueforge/src/schemas/agentMetadata.ts
Original file line number Diff line number Diff line change
@@ -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<string, never>;

export const EMPTY_AGENT_METADATA: AgentMetadata = {};
46 changes: 45 additions & 1 deletion packages/trueforge/tests/db/agentStoreContractSuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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,
Expand All @@ -66,6 +67,7 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void {
id: created.id,
name: 'research',
manifest: replacement,
metadata: {},
created_at: created.created_at,
}),
);
Expand All @@ -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(
Expand Down
Loading