Skip to content
Closed
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/20260902143524-agent-external-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge": patch
---

Add nullable agent `external_id` with a tenant-scoped partial unique index (Postgres and SQLite).
5 changes: 5 additions & 0 deletions .changeset/20260902194500-truefoundry-agent-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge": patch
---

Sync ServiceFoundry remote agents on create/update/delete via TrueFoundryAgentStore and store the remote id in `external_id`.
5 changes: 5 additions & 0 deletions .changeset/20260903010000-drop-agent-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge": patch
---

Drop unused `agent.metadata`; remote identity is stored in `external_id`.
5 changes: 5 additions & 0 deletions .changeset/20260903013000-reserve-agent-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge": patch
---

Reserve agent names `tfg` and `trueforge` in TrueFoundryAgentStore.createAgent.
3 changes: 2 additions & 1 deletion packages/trueforge/scripts/write-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function canonicalise(value: unknown): unknown {
const sessionStore = new InMemorySessionStore();
const db = createSqliteDb(':memory:');
const tokenStore = new SqliteOAuthTokenStore(db);
const agentStore = new SqliteAgentStore(db);
const app = createServerApp({
modelCatalog: ModelCatalog.load(),
resolveModelProviderStore: () => new SqliteModelProviderStore(db),
Expand All @@ -75,7 +76,7 @@ const app = createServerApp({
skillStore: new SqliteSkillStore(db),
sandboxCatalog: SandboxCatalog.load(),
sandboxProviderStore: new SqliteSandboxProviderStore(db),
agentStore: new SqliteAgentStore(db),
resolveAgentStore: () => agentStore,
scheduleStore: new SqliteScheduleStore(db),
sessionStore,
sessionMetricsStore: new SqliteSessionMetricsStore(db),
Expand Down
27 changes: 18 additions & 9 deletions packages/trueforge/src/apis/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi';
import type { AgentSpec } from '@truefoundry/trueforge-core/agent-session';
import type { Context } from 'hono';
import { AgentNameConflictError, type AgentRecord, type IAgentStore } from '../db/agentStore';
import {
AgentExternalIdConflictError,
AgentNameConflictError,
AgentNameReservedError,
type AgentRecord,
type IAgentStore,
} from '../db/agentStore';
import type { IMcpServerStore } from '../db/mcpServerStore';
import type { IModelProviderStore } from '../db/modelProviderStore';
import type { ISandboxProviderStore } from '../db/sandboxProviderStore';
Expand All @@ -24,7 +30,7 @@ import { buildAgentCodeSnippets } from './agentCodeSnippets';
import { TENANT_ID } from './sessions';

export interface AgentsRouterDeps<TTransaction> {
agentStore: IAgentStore<TTransaction>;
resolveAgentStore: (c: Context) => IAgentStore<TTransaction>;
resolveModelProviderStore: (c: Context) => IModelProviderStore<TTransaction>;
resolveMcpServerStore: (c: Context) => IMcpServerStore<TTransaction>;
skillStore: ISkillStore<TTransaction>;
Expand Down Expand Up @@ -65,7 +71,7 @@ async function validateManifest<TTransaction>({

export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransaction>) {
const listHandler: RouteHandler<typeof listAgentsRoute> = async c => {
const records = await deps.agentStore.listAgents(TENANT_ID);
const records = await deps.resolveAgentStore(c).listAgents(TENANT_ID);
return c.json({ data: records.map(toWireAgent) }, 200);
};

Expand All @@ -78,14 +84,17 @@ export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransac
mcpServerStore: deps.resolveMcpServerStore(c),
});
try {
const record = await deps.agentStore.createAgent({
const record = await deps.resolveAgentStore(c).createAgent({
tenant_id: TENANT_ID,
name: body.name,
manifest,
});
return c.json({ data: toWireAgent(record) }, 201);
} catch (error) {
if (error instanceof AgentNameConflictError) {
if (error instanceof AgentNameReservedError) {
return c.json({ error: { message: error.message } }, 400);
}
if (error instanceof AgentNameConflictError || error instanceof AgentExternalIdConflictError) {
return c.json({ error: { message: error.message } }, 409);
}
throw error;
Expand All @@ -94,7 +103,7 @@ export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransac

const getHandler: RouteHandler<typeof getAgentRoute> = async c => {
const { agent_id: agentId } = c.req.valid('param');
const record = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, id: agentId });
const record = await deps.resolveAgentStore(c).getAgent({ tenant_id: TENANT_ID, id: agentId });
if (record === undefined) {
return c.json({ error: { message: `Agent not found: ${agentId}` } }, 404);
}
Expand All @@ -103,7 +112,7 @@ export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransac

const getCodeSnippetsHandler: RouteHandler<typeof getAgentCodeSnippetsRoute> = async c => {
const { agent_id: agentId } = c.req.valid('param');
const record = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, id: agentId });
const record = await deps.resolveAgentStore(c).getAgent({ tenant_id: TENANT_ID, id: agentId });
if (record === undefined) {
return c.json({ error: { message: `Agent not found: ${agentId}` } }, 404);
}
Expand All @@ -120,7 +129,7 @@ export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransac

const deleteHandler: RouteHandler<typeof deleteAgentRoute> = async c => {
const { agent_id: agentId } = c.req.valid('param');
await deps.agentStore.deleteAgent({ tenant_id: TENANT_ID, id: agentId });
await deps.resolveAgentStore(c).deleteAgent({ tenant_id: TENANT_ID, id: agentId });
return c.json({}, 200);
};

Expand All @@ -133,7 +142,7 @@ export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransac
modelProviderStore: deps.resolveModelProviderStore(c),
mcpServerStore: deps.resolveMcpServerStore(c),
});
const record = await deps.agentStore.updateAgent({
const record = await deps.resolveAgentStore(c).updateAgent({
tenant_id: TENANT_ID,
id: agentId,
manifest,
Expand Down
6 changes: 3 additions & 3 deletions packages/trueforge/src/apis/schedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { getTurnExecutionError, startTurnInProcess, type BeginTurnExecutionDeps

export interface SchedulesRouterDeps<TTransaction> {
scheduleStore: IScheduleStore<TTransaction>;
agentStore: IAgentStore<TTransaction>;
resolveAgentStore: (c: Context) => IAgentStore<TTransaction>;
sessions: Sessions;
turnDeps: BeginTurnExecutionDeps;
withTransaction: WithTransaction<TTransaction>;
Expand Down Expand Up @@ -185,7 +185,7 @@ export function createSchedulesRouter<TTransaction>(deps: SchedulesRouterDeps<TT
await startScheduleRun({
item: { run, schedule },
sessions: deps.sessions,
agentStore: deps.agentStore,
agentStore: deps.resolveAgentStore(c),
startTurn: async turnParams => {
await startTurnInProcess({ ...turnParams, deps: deps.turnDeps });
},
Expand Down Expand Up @@ -217,7 +217,7 @@ export function createSchedulesRouter<TTransaction>(deps: SchedulesRouterDeps<TT

validateManifest(body.manifest);

const agent = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, name: body.agent_name });
const agent = await deps.resolveAgentStore(c).getAgent({ tenant_id: TENANT_ID, name: body.agent_name });
if (agent === undefined) {
return c.json({ error: { message: `Agent not found: ${body.agent_name}` } }, 400);
}
Expand Down
8 changes: 4 additions & 4 deletions packages/trueforge/src/apis/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export interface SessionsRouterDeps {
resolveModelProviderStore: (c: Context) => IModelProviderStore;
resolveMcpServerStore: (c: Context) => IMcpServerStore;
skillStore: ISkillStore;
agentStore: IAgentStore;
resolveAgentStore: (c: Context) => IAgentStore;
sandboxProviderStore: ISandboxProviderStore;
redis?: RedisClientType | undefined;
requestReplyRouter: RequestReplyRouter;
Expand Down Expand Up @@ -227,7 +227,7 @@ type InternalSessionsRouterDeps = Pick<
| 'resolveModelProviderStore'
| 'resolveMcpServerStore'
| 'skillStore'
| 'agentStore'
| 'resolveAgentStore'
| 'sandboxProviderStore'
| 'resolveUserContext'
>;
Expand All @@ -252,7 +252,7 @@ function createGetOrCreateSessionByExternalIdHandler(

let agent: SessionRecord['agent'];
if (isSessionAgentNameRef(body.agent)) {
const named = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, name: body.agent.name });
const named = await deps.resolveAgentStore(c).getAgent({ tenant_id: TENANT_ID, name: body.agent.name });
if (named === undefined) {
return c.json({ error: { message: `Agent not found: ${body.agent.name}` } }, 404);
}
Expand Down Expand Up @@ -296,7 +296,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) {
const sessionId = newId();

if (isSessionAgentNameRef(body.agent)) {
const agent = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, name: body.agent.name });
const agent = await deps.resolveAgentStore(c).getAgent({ tenant_id: TENANT_ID, name: body.agent.name });
if (agent === undefined) {
return c.json({ error: { message: `Agent not found: ${body.agent.name}` } }, 404);
}
Expand Down
8 changes: 5 additions & 3 deletions packages/trueforge/src/apis/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export interface TurnsRouterDeps {
resolveMcpServerStore: (c: Context) => IMcpServerStore;
tokenStore: IOAuthTokenStore;
skillStore: ISkillStore;
agentStore: IAgentStore;
resolveAgentStore: (c: Context) => IAgentStore;
/** Resumable live turn-event transport: create-turn writes, subscribe polls. */
eventSubscriptions: EventSubscriptionRegistry<TurnStreamingEvent>;
sandboxProviderStore: ISandboxProviderStore;
Expand All @@ -119,15 +119,16 @@ export interface TurnsRouterDeps {

/**
* Deps needed to create a turn and drain events in-process (no HTTP). Unlike the HTTP path, this
* carries already-resolved `modelProviderStore` / `mcpServerStore` (the scheduler has no request
* carries already-resolved `modelProviderStore` / `mcpServerStore` / `agentStore` (the scheduler has no request
* context to resolve them).
*/
export type BeginTurnExecutionDeps = Pick<
TurnsRouterDeps,
'activeTurns' | 'eventSubscriptions' | 'tokenStore' | 'skillStore' | 'agentStore' | 'sandboxProviderStore' | 'logger'
'activeTurns' | 'eventSubscriptions' | 'tokenStore' | 'skillStore' | 'sandboxProviderStore' | 'logger'
> & {
modelProviderStore: IModelProviderStore;
mcpServerStore: IMcpServerStore;
agentStore: IAgentStore;
};

/**
Expand Down Expand Up @@ -675,6 +676,7 @@ export function createTurnsRouter(deps: TurnsRouterDeps) {
...deps,
modelProviderStore: deps.resolveModelProviderStore(c),
mcpServerStore: deps.resolveMcpServerStore(c),
agentStore: deps.resolveAgentStore(c),
},
};

Expand Down
18 changes: 11 additions & 7 deletions packages/trueforge/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,15 @@ export interface ServerDeps<TTransaction> {
* Called without a context (e.g. the scheduler / OAuth callback) it returns the DB persistence store.
*/
resolveMcpServerStore: (c?: Context) => IMcpServerWithAuthStore<TTransaction>;
/**
* Per-request store: DB singleton, or a token-bound TrueFoundry decorator in TrueFoundry mode.
* Called without a context (e.g. the scheduler) it returns the DB persistence store.
*/
resolveAgentStore: (c?: Context) => IAgentStore<TTransaction>;
withTransaction: WithTransaction<TTransaction>;
tokenStore: IOAuthTokenStore<TTransaction>;
skillStore: ISkillStore<TTransaction>;
sandboxProviderStore: ISandboxProviderStore<TTransaction>;
agentStore: IAgentStore<TTransaction>;
scheduleStore: IScheduleStore<TTransaction>;
sessionStore: ISessionStore;
sessionMetricsStore: ISessionMetricsStore;
Expand Down Expand Up @@ -273,7 +277,7 @@ export function createServerApp<TTransaction>(deps: ServerDeps<TTransaction>) {
'/api/v1/agents',
withAuth(
createAgentsRouter({
agentStore: deps.agentStore,
resolveAgentStore: deps.resolveAgentStore,
resolveModelProviderStore: deps.resolveModelProviderStore,
resolveMcpServerStore: deps.resolveMcpServerStore,
skillStore: deps.skillStore,
Expand All @@ -287,7 +291,7 @@ export function createServerApp<TTransaction>(deps: ServerDeps<TTransaction>) {
withAuth(
createSchedulesRouter({
scheduleStore: deps.scheduleStore,
agentStore: deps.agentStore,
resolveAgentStore: deps.resolveAgentStore,
sessions: deps.sessions,
turnDeps: {
activeTurns: deps.activeTurns,
Expand All @@ -296,7 +300,7 @@ export function createServerApp<TTransaction>(deps: ServerDeps<TTransaction>) {
mcpServerStore: deps.resolveMcpServerStore(),
tokenStore: deps.tokenStore,
skillStore: deps.skillStore,
agentStore: deps.agentStore,
agentStore: deps.resolveAgentStore(),
sandboxProviderStore: deps.sandboxProviderStore,
logger: deps.logger,
},
Expand Down Expand Up @@ -328,7 +332,7 @@ export function createServerApp<TTransaction>(deps: ServerDeps<TTransaction>) {
resolveModelProviderStore: deps.resolveModelProviderStore,
resolveMcpServerStore: deps.resolveMcpServerStore,
skillStore: deps.skillStore,
agentStore: deps.agentStore,
resolveAgentStore: deps.resolveAgentStore,
sandboxProviderStore: deps.sandboxProviderStore,
resolveUserContext,
}),
Expand All @@ -353,7 +357,7 @@ export function createServerApp<TTransaction>(deps: ServerDeps<TTransaction>) {
resolveModelProviderStore: deps.resolveModelProviderStore,
resolveMcpServerStore: deps.resolveMcpServerStore,
skillStore: deps.skillStore,
agentStore: deps.agentStore,
resolveAgentStore: deps.resolveAgentStore,
sandboxProviderStore: deps.sandboxProviderStore,
redis: deps.redis,
requestReplyRouter: deps.requestReplyRouter,
Expand All @@ -373,7 +377,7 @@ export function createServerApp<TTransaction>(deps: ServerDeps<TTransaction>) {
resolveMcpServerStore: deps.resolveMcpServerStore,
tokenStore: deps.tokenStore,
skillStore: deps.skillStore,
agentStore: deps.agentStore,
resolveAgentStore: deps.resolveAgentStore,
eventSubscriptions: deps.eventSubscriptions,
sandboxProviderStore: deps.sandboxProviderStore,
logger: deps.logger,
Expand Down
2 changes: 1 addition & 1 deletion packages/trueforge/src/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function requireAccessToken(c: Context): string {
const token = readAccessToken(c);
if (!token) {
throw new HTTPException(401, {
message: 'Authentication token required to list or call TrueFoundry models and MCP servers',
message: 'Authentication token required to list or call TrueFoundry models, MCP servers, and agents',
});
}
return token;
Expand Down
36 changes: 30 additions & 6 deletions packages/trueforge/src/db/agentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@
* 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;
external_id: string | null;
/** ISO-8601 UTC instant. */
created_at: string;
/** ISO-8601 UTC instant. */
Expand All @@ -35,17 +34,18 @@ export interface CreateAgentInput {
tenant_id: string;
name: ResourceName;
manifest: AgentSpec;
external_id?: string | null;
}

/**
* Patch an existing agent by immutable id. At least one of `manifest` or `metadata` is required.
* Patch an existing agent by immutable id. At least one of `manifest` or `external_id` is required.
* Provided fields replace the stored column; omitted fields are left unchanged.
*/
export interface UpdateAgentInput {
tenant_id: string;
id: string;
manifest?: AgentSpec;
metadata?: AgentMetadata;
external_id?: string | null;
}

export interface DeleteAgentInput {
Expand All @@ -66,12 +66,36 @@ export class AgentNameConflictError extends Error {
}
}

/** Unique `(tenant_id, external_id)` violation when `external_id` is set. */
export class AgentExternalIdConflictError extends Error {
readonly tenant_id: string;
readonly external_id: string;

constructor({ tenant_id, external_id }: { tenant_id: string; external_id: string }, options?: ErrorOptions) {
super(`Agent already exists for external id: ${external_id}`, options);
this.name = 'AgentExternalIdConflictError';
this.tenant_id = tenant_id;
this.external_id = external_id;
}
}

/** Name reserved for product / control-plane use (TrueFoundryAgentStore). */
export class AgentNameReservedError extends Error {
readonly agent_name: string;

constructor({ name }: { name: string }, options?: ErrorOptions) {
super(`Agent name is reserved: ${name}`, options);
this.name = 'AgentNameReservedError';
this.agent_name = name;
}
}

export interface IAgentStore<TTransaction = never> {
listAgents(tenantId: string, transaction?: TTransaction): Promise<AgentRecord[]>;
getAgent(input: GetAgentInput, transaction?: TTransaction): Promise<AgentRecord | undefined>;
/** Inserts a new agent with a generated ULID. Throws AgentNameConflictError on name clash. */
/** Inserts a new agent with a generated ULID. Throws AgentNameConflictError or AgentExternalIdConflictError on unique clash. */
createAgent(input: CreateAgentInput, transaction?: TTransaction): Promise<AgentRecord>;
/** Patches `manifest` and/or `metadata` for an existing id. Returns undefined if missing. */
/** Patches `manifest` and/or `external_id`. Throws AgentExternalIdConflictError on unique clash. 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
3 changes: 3 additions & 0 deletions packages/trueforge/src/db/indexes.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
/** Partial unique index on `session (tenant_id, external_id) WHERE external_id IS NOT NULL`. */
export const SESSION_EXTERNAL_ID_UQ = 'session_external_id_uq';

/** Partial unique index on `agent (tenant_id, external_id) WHERE external_id IS NOT NULL`. */
export const AGENT_EXTERNAL_ID_UQ = 'agent_external_id_uq';
Loading
Loading