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/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 and store the remote id in `external_id`. Filter `listAgents` by `external_ids`. Keep general ServiceFoundry HTTP at 10s and agent CRUD calls at 3s.
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
---

Reject reserved agent names `tfg` and `trueforge` in create requests.
4 changes: 4 additions & 0 deletions packages/trueforge/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ PORT=8790
## ServiceFoundry server (caller token) and turns call the tenant's default AI Gateway. Unset = local
## API-key model catalog. Mutually exclusive with OIDC.
# TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL=https://servicefoundry-server.truefoundry.svc.cluster.local
## Max ms for non-agent ServiceFoundry HTTP calls. Default 10000.
# TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS=10000
## Max ms for ServiceFoundry agent create/update/delete calls. Default 3000.
# TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS=3000

## Internal mutual TLS for calls to the ServiceFoundry server. When true, trueforge presents its client
## certificate (and upgrades a mesh-direct http:// peer URL to https://). Only meaningful in-cluster,
Expand Down
3 changes: 2 additions & 1 deletion packages/trueforge/scripts/write-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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 @@ -76,7 +77,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
23 changes: 14 additions & 9 deletions packages/trueforge/src/apis/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi';
import type { AgentSpec } from '@truefoundry/trueforge-core/agent-session';
import type { Context } from 'hono';
import { createdBySubjectFromRequestContext, type ResolveRequestContext } from '../auth/identity';
import { AgentNameConflictError, type AgentRecord, type IAgentStore } from '../db/agentStore';
import {
AgentExternalIdConflictError,
AgentNameConflictError,
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 +29,7 @@ import { type Agent, type CreateAgentRequest } from '../schemas/agent';
import { buildAgentCodeSnippets } from './agentCodeSnippets';

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 @@ -70,7 +75,7 @@ async function validateManifest<TTransaction>({
export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransaction>) {
const listHandler: RouteHandler<typeof listAgentsRoute> = async c => {
const requestContext = deps.resolveRequestContext(c);
const records = await deps.agentStore.listAgents(requestContext.tenant_id);
const records = await deps.resolveAgentStore(c).listAgents({ tenant_id: requestContext.tenant_id });
return c.json({ data: records.map(toWireAgent) }, 200);
};

Expand All @@ -85,7 +90,7 @@ export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransac
tenant_id: requestContext.tenant_id,
});
try {
const record = await deps.agentStore.createAgent({
const record = await deps.resolveAgentStore(c).createAgent({
tenant_id: requestContext.tenant_id,
name: body.name,
manifest,
Expand All @@ -94,7 +99,7 @@ export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransac
});
return c.json({ data: toWireAgent(record) }, 201);
} catch (error) {
if (error instanceof AgentNameConflictError) {
if (error instanceof AgentNameConflictError || error instanceof AgentExternalIdConflictError) {
return c.json({ error: { message: error.message } }, 409);
}
throw error;
Expand All @@ -104,7 +109,7 @@ export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransac
const getHandler: RouteHandler<typeof getAgentRoute> = async c => {
const { agent_id: agentId } = c.req.valid('param');
const requestContext = deps.resolveRequestContext(c);
const record = await deps.agentStore.getAgent({
const record = await deps.resolveAgentStore(c).getAgent({
tenant_id: requestContext.tenant_id,
id: agentId,
});
Expand All @@ -117,7 +122,7 @@ export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransac
const getCodeSnippetsHandler: RouteHandler<typeof getAgentCodeSnippetsRoute> = async c => {
const { agent_id: agentId } = c.req.valid('param');
const requestContext = deps.resolveRequestContext(c);
const record = await deps.agentStore.getAgent({
const record = await deps.resolveAgentStore(c).getAgent({
tenant_id: requestContext.tenant_id,
id: agentId,
});
Expand All @@ -138,7 +143,7 @@ export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransac
const deleteHandler: RouteHandler<typeof deleteAgentRoute> = async c => {
const { agent_id: agentId } = c.req.valid('param');
const requestContext = deps.resolveRequestContext(c);
await deps.agentStore.deleteAgent({ tenant_id: requestContext.tenant_id, id: agentId });
await deps.resolveAgentStore(c).deleteAgent({ tenant_id: requestContext.tenant_id, id: agentId });
return c.json({}, 200);
};

Expand All @@ -153,7 +158,7 @@ export function createAgentsRouter<TTransaction>(deps: AgentsRouterDeps<TTransac
mcpServerStore: deps.resolveMcpServerStore(c),
tenant_id: requestContext.tenant_id,
});
const record = await deps.agentStore.updateAgent({
const record = await deps.resolveAgentStore(c).updateAgent({
tenant_id: requestContext.tenant_id,
id: agentId,
manifest,
Expand Down
7 changes: 4 additions & 3 deletions packages/trueforge/src/apis/schedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi';
import { InvalidPageTokenError, type Sessions } from '@truefoundry/trueforge-core/agent-session';
import type { Context } from 'hono';
import {
createdBySubjectFromRequestContext,
hasAdminRole,
Expand Down Expand Up @@ -41,7 +42,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 @@ -200,7 +201,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 @@ -235,7 +236,7 @@ export function createSchedulesRouter<TTransaction>(deps: SchedulesRouterDeps<TT

validateManifest(body.manifest);

const agent = await deps.agentStore.getAgent({
const agent = await deps.resolveAgentStore(c).getAgent({
tenant_id: requestContext.tenant_id,
name: body.agent_name,
});
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 @@ -75,7 +75,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 @@ -230,7 +230,7 @@ type InternalSessionsRouterDeps = Pick<
| 'resolveModelProviderStore'
| 'resolveMcpServerStore'
| 'skillStore'
| 'agentStore'
| 'resolveAgentStore'
| 'sandboxProviderStore'
| 'resolveRequestContext'
>;
Expand Down Expand Up @@ -260,7 +260,7 @@ function createGetOrCreateSessionByExternalIdHandler(

let agent: SessionRecord['agent'];
if (isSessionAgentNameRef(body.agent)) {
const named = await deps.agentStore.getAgent({
const named = await deps.resolveAgentStore(c).getAgent({
tenant_id: requestContext.tenant_id,
name: body.agent.name,
});
Expand Down Expand Up @@ -314,7 +314,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) {
const requestContext = deps.resolveRequestContext(c);

if (isSessionAgentNameRef(body.agent)) {
const agent = await deps.agentStore.getAgent({
const agent = await deps.resolveAgentStore(c).getAgent({
tenant_id: requestContext.tenant_id,
name: body.agent.name,
});
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 @@ -106,7 +106,7 @@ export interface TurnsRouterDeps {
resolveModelProviderStore: (c: Context) => IModelProviderStore;
resolveMcpServerStore: (c: Context) => IMcpServerWithAuthStore;
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 @@ -116,15 +116,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' | 'skillStore' | 'agentStore' | 'sandboxProviderStore' | 'logger'
'activeTurns' | 'eventSubscriptions' | 'skillStore' | 'sandboxProviderStore' | 'logger'
> & {
modelProviderStore: IModelProviderStore;
mcpServerStore: IMcpServerWithAuthStore;
agentStore: IAgentStore;
};

/**
Expand Down Expand Up @@ -721,6 +722,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 @@ -174,11 +174,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>;
Comment thread
bhaveshpatel640 marked this conversation as resolved.
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 @@ -291,7 +295,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 @@ -307,15 +311,15 @@ 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,
eventSubscriptions: deps.eventSubscriptions,
modelProviderStore: deps.resolveModelProviderStore(),
mcpServerStore: deps.resolveMcpServerStore(),
skillStore: deps.skillStore,
agentStore: deps.agentStore,
agentStore: deps.resolveAgentStore(),
sandboxProviderStore: deps.sandboxProviderStore,
logger: deps.logger,
},
Expand Down Expand Up @@ -349,7 +353,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,
resolveRequestContext,
}),
Expand All @@ -376,7 +380,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 @@ -396,7 +400,7 @@ export function createServerApp<TTransaction>(deps: ServerDeps<TTransaction>) {
resolveModelProviderStore: deps.resolveModelProviderStore,
resolveMcpServerStore: deps.resolveMcpServerStore,
skillStore: deps.skillStore,
agentStore: deps.agentStore,
resolveAgentStore: deps.resolveAgentStore,
eventSubscriptions: deps.eventSubscriptions,
sandboxProviderStore: deps.sandboxProviderStore,
logger: deps.logger,
Expand Down
14 changes: 14 additions & 0 deletions packages/trueforge/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,10 @@ export interface SharedServerConfiguration {
* Env: `TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL`.
*/
TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL: string | undefined;
/** Max ms for non-agent ServiceFoundry HTTP calls. Env: `TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS`. Default 10000. */
TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS: number;
/** Max ms for agent CRUD ServiceFoundry HTTP calls. Env: `TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS`. Default 3000. */
TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS: number;
/**
* Present this pod's client certificate on outbound calls to the ServiceFoundry server (internal
* mutual TLS) and upgrade a mesh-direct peer URL from http to https. Off by default, so an
Expand Down Expand Up @@ -664,6 +668,16 @@ const shared: SharedServerConfiguration = {
SERVER_URL:
getEnv('SERVER_URL', { defaultValue: `http://localhost:${String(port)}` }) ?? `http://localhost:${String(port)}`,
TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL: getEnv('TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL', { required: false }),
TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS: parsePositiveInt({
envKey: 'TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS',
raw: getEnv('TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS'),
defaultValue: 10_000,
}),
TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS: parsePositiveInt({
envKey: 'TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS',
raw: getEnv('TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS'),
defaultValue: 3_000,
}),
TRUEFOUNDRY_MTLS_ENABLED: parseBoolean({
envKey: 'TRUEFOUNDRY_MTLS_ENABLED',
raw: getEnv('TRUEFOUNDRY_MTLS_ENABLED'),
Expand Down
8 changes: 7 additions & 1 deletion packages/trueforge/src/db/agentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export function parseStoredAgentSpec(manifest: unknown): AgentSpec {
/** Look up by immutable id or unique name within a tenant. */
export type GetAgentInput = { tenant_id: string } & ({ id: string } | { name: string });

export interface ListAgentsInput {
tenant_id: string;
/** When set, only agents whose `external_id` is in this list. */
external_ids?: readonly string[];
}

export interface CreateAgentInput {
tenant_id: string;
name: ResourceName;
Expand Down Expand Up @@ -82,7 +88,7 @@ export class AgentExternalIdConflictError extends Error {
}

export interface IAgentStore<TTransaction = never> {
listAgents(tenantId: string, transaction?: TTransaction): Promise<AgentRecord[]>;
listAgents(input: ListAgentsInput, transaction?: TTransaction): Promise<AgentRecord[]>;
getAgent(input: GetAgentInput, transaction?: TTransaction): Promise<AgentRecord | undefined>;
/** Inserts a new agent with a generated ULID. Throws AgentNameConflictError or AgentExternalIdConflictError on unique clash. */
createAgent(input: CreateAgentInput, transaction?: TTransaction): Promise<AgentRecord>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type DeleteAgentInput,
type GetAgentInput,
type IAgentStore,
type ListAgentsInput,
type UpdateAgentInput,
} from '../../agentStore';
import { parseStoredCreatedBySubject } from '../../createdBySubject';
Expand Down Expand Up @@ -55,9 +56,16 @@ export class PostgresAgentStore implements IAgentStore<Transaction<Database>> {
this.#db = db;
}

async listAgents(tenantId: string, transaction?: Transaction<Database>): Promise<AgentRecord[]> {
async listAgents(input: ListAgentsInput, transaction?: Transaction<Database>): Promise<AgentRecord[]> {
if (input.external_ids?.length === 0) {
return [];
}
const db = transaction ?? this.#db;
const rows = await db.selectFrom('agent').selectAll().where('tenant_id', '=', tenantId).orderBy('name').execute();
let query = db.selectFrom('agent').selectAll().where('tenant_id', '=', input.tenant_id);
if (input.external_ids !== undefined) {
query = query.where('external_id', 'in', [...input.external_ids]);
}
const rows = await query.orderBy('name').execute();
return rows.map(toRecord);
}

Expand All @@ -73,6 +81,10 @@ export class PostgresAgentStore implements IAgentStore<Transaction<Database>> {
return row === undefined ? undefined : toRecord(row);
}

withTransaction<T>(fn: (transaction: Transaction<Database>) => Promise<T>): Promise<T> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this? Why can't we take db as an argument in the TrueFoundryAgentStore Constructor?

return this.#db.transaction().execute(fn);
}

async createAgent(input: CreateAgentInput, transaction?: Transaction<Database>): Promise<AgentRecord> {
const db = transaction ?? this.#db;
try {
Expand Down
Loading