From 4e5644c26749bece29698a2b8a6e0bc85a856d01 Mon Sep 17 00:00:00 2001 From: Srajan Asthana Date: Wed, 16 Sep 2026 19:56:04 +0530 Subject: [PATCH 1/4] Keep TrueFoundry metadata off the turns engine. Parse request metadata at create-turn and supply generic turnHeaders after the turn id exists, so beginTurnExecution no longer takes tfy-specific fields. Co-authored-by: Cursor --- packages/trueforge/src/apis/turns.ts | 59 +++++++++---------- .../trueforge/src/runtime/sessionResources.ts | 6 +- .../unit/runtime/sessionResources.test.ts | 6 +- 3 files changed, 34 insertions(+), 37 deletions(-) diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 42480a5a5..15684ff9e 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -135,6 +135,18 @@ export type BeginTurnExecutionDeps = Pick; }; +/** Extra LLM/MCP headers resolved after turn id is minted. */ +type ResolveTurnHeaders = (input: { session: SessionHandle; turnId: string }) => Record; + +interface BeginTurnExecutionParams { + session: SessionHandle; + input: TurnInputItem[] | undefined; + previous_turn_id: string | undefined; + userRef: string; + turnHeaders?: ResolveTurnHeaders; + deps: BeginTurnExecutionDeps; +} + /** * Builds the per-turn resolver. Agent / MCP / sandbox / LLM lookups are wired * the same way: async factories over the corresponding stores. @@ -149,8 +161,7 @@ function createTurnResolver(deps: { signal: AbortSignal; userRef: string; session: SessionHandle; - turnId: string; - tfyMetadata: Record | undefined; + turnHeaders: Record; }): TurnResourceResolver { const { mcpServerStore, @@ -162,14 +173,10 @@ function createTurnResolver(deps: { signal, userRef, session, - turnId, - tfyMetadata, + turnHeaders, } = deps; const tenant_id = session.tenant_id; const sessionId = session.session_id; - const metadataHeaders = isTrueFoundryModeEnabled() - ? gatewayMetadataHeaders(mergeGatewayMetadata({ session, turnId, tfyMetadata })) - : {}; return new TurnResourceResolver({ llm: async name => { @@ -182,7 +189,7 @@ function createTurnResolver(deps: { modelClient: new VercelAILLM({ providerConfig: { ...resolved.providerConfig, - headers: { ...resolved.providerConfig.headers, ...metadataHeaders }, + headers: { ...resolved.providerConfig.headers, ...turnHeaders }, }, logger, signal, @@ -207,7 +214,7 @@ function createTurnResolver(deps: { url: connection.url, headers: withGatewayMetadataHeaders({ headers: connection.headers, - metadataHeaders, + metadataHeaders: turnHeaders, }), }; }, @@ -386,15 +393,10 @@ export interface TurnEventDrainInput { * Shared create-turn engine: persist the turn, start execution, and return the * drain inputs. Does not wait for events and does not write HTTP/SSE. */ -export async function beginTurnExecution(params: { - session: SessionHandle; - input: TurnInputItem[] | undefined; - previous_turn_id: string | undefined; - userRef: string; - tfyMetadata?: Record | undefined; - deps: BeginTurnExecutionDeps; -}): Promise<{ turn: TurnHandle; drainInput: TurnEventDrainInput }> { - const { session, input, previous_turn_id: previousTurnId, userRef, tfyMetadata, deps } = params; +export async function beginTurnExecution( + params: BeginTurnExecutionParams, +): Promise<{ turn: TurnHandle; drainInput: TurnEventDrainInput }> { + const { session, input, previous_turn_id: previousTurnId, userRef, turnHeaders, deps } = params; const sessionId = session.session_id; const turnId = mintPeeredTurnId(configuration.EXECUTOR_ID); @@ -410,8 +412,7 @@ export async function beginTurnExecution(params: { signal: abortController.signal, userRef, session, - turnId, - tfyMetadata, + turnHeaders: turnHeaders?.({ session, turnId }) ?? {}, }); // First turn only: derive the title from the first user message. The store @@ -461,14 +462,7 @@ export async function beginTurnExecution(params: { * Non-stream create-turn: begin execution and resolve once the first event is * dual-written so immediate subscribe cannot 412. Same as `stream: false`. */ -export async function startTurnInProcess(params: { - session: SessionHandle; - input: TurnInputItem[] | undefined; - previous_turn_id: string | undefined; - userRef: string; - tfyMetadata?: Record | undefined; - deps: BeginTurnExecutionDeps; -}): Promise { +export async function startTurnInProcess(params: BeginTurnExecutionParams): Promise { const { turn, drainInput } = await beginTurnExecution(params); // Same unawaited drain scheduling as Hono streamSSE's run(cb). @@ -775,14 +769,17 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { } const rawTfyMetadata = c.req.header(X_TFY_METADATA); - const tfyMetadata = rawTfyMetadata === undefined ? undefined : parseGatewayMetadataHeader(rawTfyMetadata); + const requestMetadata = rawTfyMetadata === undefined ? undefined : parseGatewayMetadataHeader(rawTfyMetadata); - const turnParams = { + const turnParams: BeginTurnExecutionParams = { session, input: body.input, previous_turn_id: body.previous_turn_id, userRef: requestContext.subject.id, - tfyMetadata, + turnHeaders: ({ session: turnSession, turnId }) => + isTrueFoundryModeEnabled() + ? gatewayMetadataHeaders(mergeGatewayMetadata({ session: turnSession, turnId, requestMetadata })) + : {}, deps: { ...deps, modelProviderStore: deps.resolveModelProviderStore(c, referencedAgent), diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index ce75c31ef..0834209f3 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -72,14 +72,14 @@ export function buildGatewayMetadata(input: { session: SessionHandle; turnId: st return metadata; } -/** Inbound x-tfy-metadata first; harness tfg.* always win */ +/** Caller requestMetadata first; harness tfg.* always win */ export function mergeGatewayMetadata(input: { session: SessionHandle; turnId: string; - tfyMetadata?: Record | undefined; + requestMetadata?: Record | undefined; }): Record { return { - ...input.tfyMetadata, + ...input.requestMetadata, ...buildGatewayMetadata({ session: input.session, turnId: input.turnId }), }; } diff --git a/packages/trueforge/tests/unit/runtime/sessionResources.test.ts b/packages/trueforge/tests/unit/runtime/sessionResources.test.ts index 376665160..39cad7143 100644 --- a/packages/trueforge/tests/unit/runtime/sessionResources.test.ts +++ b/packages/trueforge/tests/unit/runtime/sessionResources.test.ts @@ -80,7 +80,7 @@ describe('buildGatewayMetadata', () => { }); describe('mergeGatewayMetadata', () => { - it('keeps tfyMetadata keys and overwrites spoofed tfg.* fields so order is maintained', async () => { + it('keeps requestMetadata keys and overwrites spoofed tfg.* fields so order is maintained', async () => { const session = await createGatewayMetadataSession({ agent: { type: 'reference', id: 'agent-1', name: 'my-agent' }, }); @@ -89,7 +89,7 @@ describe('mergeGatewayMetadata', () => { mergeGatewayMetadata({ session, turnId: 'turn-1', - tfyMetadata: { + requestMetadata: { env: 'prod', [`${TFG_METADATA_PREFIX}.session_id`]: 'spoofed-session', [`${TFG_METADATA_PREFIX}.turn_id`]: 'spoofed-turn', @@ -106,7 +106,7 @@ describe('mergeGatewayMetadata', () => { }); }); - it('matches harness-only stamps when tfyMetadata is absent', async () => { + it('matches harness-only stamps when requestMetadata is absent', async () => { const session = await createGatewayMetadataSession({ agent: { type: 'reference', id: 'agent-1', name: 'my-agent' }, }); From ac7ba05c4f9d599572a3448c49bb8a7ec66ec118 Mon Sep 17 00:00:00 2001 From: Srajan Asthana Date: Mon, 21 Sep 2026 17:04:57 +0530 Subject: [PATCH 2/4] nit --- .changeset/schedule-turn-gateway-metadata.md | 5 +++++ packages/trueforge/src/apis/schedules.ts | 2 ++ packages/trueforge/src/apis/turns.ts | 14 ++++++-------- .../trueforge/src/runtime/sessionResources.ts | 17 ++++++++++++++++- 4 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 .changeset/schedule-turn-gateway-metadata.md diff --git a/.changeset/schedule-turn-gateway-metadata.md b/.changeset/schedule-turn-gateway-metadata.md new file mode 100644 index 000000000..74b83648f --- /dev/null +++ b/.changeset/schedule-turn-gateway-metadata.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Keep TrueFoundry gateway metadata out of the turns engine behind a required per-turn headers resolver, and keep stamping harness `tfg.*` metadata on schedule-run turns. diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index da3dcc62d..0689915c0 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -52,6 +52,7 @@ import { import type { ActiveTurnRegistry } from '../runtime/activeTurns'; import { minIntervalSeconds, nextTriggerAfter } from '../runtime/cron'; import type { EventSubscriptionRegistry } from '../runtime/event-subscription'; +import { gatewayTurnHeaders } from '../runtime/sessionResources'; import { InvalidCronError, SCHEDULE_MIN_INTERVAL_SECONDS, @@ -108,6 +109,7 @@ export async function startScheduleRunOnRequest(params: { input: prepared.input, previous_turn_id: prepared.previous_turn_id, userRef: prepared.userRef, + turnHeaders: ({ session, turnId }) => gatewayTurnHeaders({ session, turnId }), deps: { activeTurns: deps.activeTurns, eventSubscriptions: deps.eventSubscriptions, diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 15684ff9e..819b9bb74 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -32,7 +32,7 @@ import { streamSSE } from 'hono/streaming'; import type { Logger } from 'winston'; import type { Authorizer } from '../auth/authorizer'; import type { ResolveRequestContext } from '../auth/identity'; -import configuration, { isTrueFoundryModeEnabled } from '../config'; +import configuration from '../config'; import type { AgentRecord, IAgentStore } from '../db/agentStore'; import type { IMcpServerWithAuthStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; @@ -52,10 +52,9 @@ import { mintPeeredTurnId } from '../runtime/peeringIds'; import { validateSandboxFilePath } from '../runtime/sandboxFilePath'; import { buildTurnSandbox, - gatewayMetadataHeaders, + gatewayTurnHeaders, getMcpConnection, getModelDetails, - mergeGatewayMetadata, parseGatewayMetadataHeader, resolveSandboxProvider, withGatewayMetadataHeaders, @@ -143,7 +142,8 @@ interface BeginTurnExecutionParams { input: TurnInputItem[] | undefined; previous_turn_id: string | undefined; userRef: string; - turnHeaders?: ResolveTurnHeaders; + /** Required so no turn start path can silently drop gateway metadata (see `gatewayTurnHeaders`). */ + turnHeaders: ResolveTurnHeaders; deps: BeginTurnExecutionDeps; } @@ -412,7 +412,7 @@ export async function beginTurnExecution( signal: abortController.signal, userRef, session, - turnHeaders: turnHeaders?.({ session, turnId }) ?? {}, + turnHeaders: turnHeaders({ session, turnId }), }); // First turn only: derive the title from the first user message. The store @@ -777,9 +777,7 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { previous_turn_id: body.previous_turn_id, userRef: requestContext.subject.id, turnHeaders: ({ session: turnSession, turnId }) => - isTrueFoundryModeEnabled() - ? gatewayMetadataHeaders(mergeGatewayMetadata({ session: turnSession, turnId, requestMetadata })) - : {}, + gatewayTurnHeaders({ session: turnSession, turnId, requestMetadata }), deps: { ...deps, modelProviderStore: deps.resolveModelProviderStore(c, referencedAgent), diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 0834209f3..8e0b5c0b7 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -14,7 +14,7 @@ import { HTTPException } from 'hono/http-exception'; import { join } from 'node:path'; import type { Logger } from 'winston'; import { z } from 'zod'; -import configuration from '../config'; +import configuration, { isTrueFoundryModeEnabled } from '../config'; import type { IMcpServerStore, IMcpServerWithAuthStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; @@ -91,6 +91,21 @@ export function gatewayMetadataHeaders(metadata: Record): Record return { [X_TFY_METADATA]: JSON.stringify(metadata) }; } +/** + * Per-turn gateway headers for LLM/MCP calls: harness tfg.* stamps over caller + * metadata. Empty outside TrueFoundry mode. Every turn start must wire this in. + */ +export function gatewayTurnHeaders(input: { + session: SessionHandle; + turnId: string; + requestMetadata?: Record | undefined; +}): Record { + if (!isTrueFoundryModeEnabled()) { + return {}; + } + return gatewayMetadataHeaders(mergeGatewayMetadata(input)); +} + /** * Merge gateway metadata into MCP invoke headers. Preserves authRequired; * metadata is applied after auth/per-server headers. From 9a92f0df85d1c105c1fa493bbc4c67e2beaec9c2 Mon Sep 17 00:00:00 2001 From: Srajan Asthana Date: Mon, 21 Sep 2026 17:05:41 +0530 Subject: [PATCH 3/4] nit --- packages/trueforge/src/apis/turns.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 819b9bb74..04a09006e 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -142,7 +142,6 @@ interface BeginTurnExecutionParams { input: TurnInputItem[] | undefined; previous_turn_id: string | undefined; userRef: string; - /** Required so no turn start path can silently drop gateway metadata (see `gatewayTurnHeaders`). */ turnHeaders: ResolveTurnHeaders; deps: BeginTurnExecutionDeps; } From 237b6697746cfe869a74fdc4d45c506aa194c23d Mon Sep 17 00:00:00 2001 From: Srajan Asthana Date: Mon, 21 Sep 2026 19:35:18 +0530 Subject: [PATCH 4/4] nit --- packages/trueforge/src/apis/schedules.ts | 2 +- packages/trueforge/src/apis/turns.ts | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index 0689915c0..64afc962e 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -109,7 +109,7 @@ export async function startScheduleRunOnRequest(params: { input: prepared.input, previous_turn_id: prepared.previous_turn_id, userRef: prepared.userRef, - turnHeaders: ({ session, turnId }) => gatewayTurnHeaders({ session, turnId }), + resolveTurnHeaders: gatewayTurnHeaders, deps: { activeTurns: deps.activeTurns, eventSubscriptions: deps.eventSubscriptions, diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index c47d942d4..e47e67f75 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -145,7 +145,7 @@ interface BeginTurnExecutionParams { input: TurnInputItem[] | undefined; previous_turn_id: string | undefined; userRef: string; - turnHeaders: ResolveTurnHeaders; + resolveTurnHeaders: ResolveTurnHeaders; deps: BeginTurnExecutionDeps; } @@ -400,7 +400,7 @@ export interface TurnEventDrainInput { export async function beginTurnExecution( params: BeginTurnExecutionParams, ): Promise<{ turn: TurnHandle; drainInput: TurnEventDrainInput }> { - const { session, input, previous_turn_id: previousTurnId, userRef, turnHeaders, deps } = params; + const { session, input, previous_turn_id: previousTurnId, userRef, resolveTurnHeaders, deps } = params; const sessionId = session.session_id; const turnId = newId(); @@ -417,7 +417,7 @@ export async function beginTurnExecution( signal: abortController.signal, userRef, session, - turnHeaders: turnHeaders({ session, turnId }), + turnHeaders: resolveTurnHeaders({ session, turnId }), }); // First turn only: derive the title from the first user message. The store @@ -782,8 +782,7 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { input: body.input, previous_turn_id: body.previous_turn_id, userRef: requestContext.subject.id, - turnHeaders: ({ session: turnSession, turnId }) => - gatewayTurnHeaders({ session: turnSession, turnId, requestMetadata }), + resolveTurnHeaders: input => gatewayTurnHeaders({ ...input, requestMetadata }), deps: { ...deps, modelProviderStore: deps.resolveModelProviderStore(c, referencedAgent),