From e3ccc11d38f00c51c0f53b8c5cc604c941eb70d1 Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Wed, 28 Oct 2026 12:27:35 +0530 Subject: [PATCH 1/9] feat: accept x-tfg-tools so a turn can mount ask_user_secret A TrueFoundry-mode turn can enable the client-side secret tool on the root agent. The header is not stored, and the secret value never enters the harness. Signed-off-by: Raman Tehlan --- .changeset/x-tfg-tools-ask-user-secret.md | 6 + .../src/containers/ToolCallContainer.tsx | 6 + .../trueforge-ui/src/utils/toolCallParsing.ts | 1 + .../containers/ToolCallContainer.test.tsx | 62 ++++++ packages/trueforge/src/apis/turns.ts | 197 ++++++++++-------- .../TrueFoundryTurnResourceResolver.ts | 25 +++ .../trueforge/src/truefoundry/tfgTools.ts | 120 +++++++++++ .../TrueFoundryTurnResourceResolver.test.ts | 60 ++++++ .../tests/unit/truefoundry/tfgTools.test.ts | 73 +++++++ 9 files changed, 460 insertions(+), 90 deletions(-) create mode 100644 .changeset/x-tfg-tools-ask-user-secret.md create mode 100644 packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts create mode 100644 packages/trueforge/src/truefoundry/tfgTools.ts create mode 100644 packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts create mode 100644 packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts diff --git a/.changeset/x-tfg-tools-ask-user-secret.md b/.changeset/x-tfg-tools-ask-user-secret.md new file mode 100644 index 0000000000..d1f2a8c7c9 --- /dev/null +++ b/.changeset/x-tfg-tools-ask-user-secret.md @@ -0,0 +1,6 @@ +--- +'@truefoundry/trueforge': patch +'@truefoundry/trueforge-ui': patch +--- + +Accept `x-tfg-tools` on a TrueFoundry-mode turn so the root agent can call `ask_user_secret`. Hide that pending call in live chat so the host secret form owns the prompt. diff --git a/packages/trueforge-ui/src/containers/ToolCallContainer.tsx b/packages/trueforge-ui/src/containers/ToolCallContainer.tsx index b5777d6948..15622a174a 100644 --- a/packages/trueforge-ui/src/containers/ToolCallContainer.tsx +++ b/packages/trueforge-ui/src/containers/ToolCallContainer.tsx @@ -19,6 +19,7 @@ import { AnalyticsEvents } from '../analytics/events.js'; import { useSlot } from '../theme/SlotsProvider.js'; import { resolveActivityLabel } from '../utils/resolveActivityLabel.js'; import { + ASK_USER_SECRET_TOOL_NAME, ASK_USER_TOOL_NAME, MCP_META_TOOLS, SANDBOX_TOOL_NAMES, @@ -182,6 +183,11 @@ export const ToolCallContainer: ToolCallMessagePartComponent = part => { const expandSubAgent = useCallback(() => setExpanded(true), []); useRegisterApprovalExpand(isSubAgent ? part.toolCallId : '', expandSubAgent); + // Live chat: the host secret form owns this pause. A thread card would repeat the field list. + if (part.toolName === ASK_USER_SECRET_TOOL_NAME && !isSessionReplay && getAskUserAnswerResult(part.result) == null) { + return null; + } + if (part.toolName === ASK_USER_TOOL_NAME) { const { question, options = [] } = parseAskUserQuestionArgs(part.argsText); const answer = getAskUserAnswerResult(part.result); diff --git a/packages/trueforge-ui/src/utils/toolCallParsing.ts b/packages/trueforge-ui/src/utils/toolCallParsing.ts index 6a7877aefb..4572673f31 100644 --- a/packages/trueforge-ui/src/utils/toolCallParsing.ts +++ b/packages/trueforge-ui/src/utils/toolCallParsing.ts @@ -13,6 +13,7 @@ export type ApprovalOptionView = { export const SUB_AGENT_TOOL_NAME = 'create_sub_agent'; export const ASK_USER_TOOL_NAME = 'ask_user_question'; +export const ASK_USER_SECRET_TOOL_NAME = 'ask_user_secret'; /** Tool names that should be rendered as a sandbox execution card. */ export const SANDBOX_TOOL_NAMES = new Set(['exec', 'sandbox_exec']); diff --git a/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx b/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx index 70e380283b..b2744a03f4 100644 --- a/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx @@ -193,6 +193,68 @@ describe('ToolCallContainer', () => { ); }); + it('hides pending ask_user_secret in live chat so the host form owns the prompt', () => { + renderToolCallMessage([ + { + type: 'tool-call', + toolCallId: 'secret-pending', + toolName: 'ask_user_secret', + args: {}, + argsText: JSON.stringify({ + question: 'Provide the API key', + fields: [{ key: 'api_key', label: 'API key', required: true }], + }), + interrupt: { type: 'human', payload: {} }, + }, + ]); + + expect(screen.queryByText('ask_user_secret')).not.toBeInTheDocument(); + }); + + it('shows a pending ask_user_secret card in session replay', () => { + const message: ThreadMessageLike = { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'secret-pending', + toolName: 'ask_user_secret', + args: {}, + argsText: JSON.stringify({ + question: 'Provide the API key', + fields: [{ key: 'api_key', label: 'API key', required: true }], + }), + interrupt: { type: 'human', payload: {} }, + }, + ], + }; + + render( + + + {() => } + + , + ); + + expect(screen.getByText('ask_user_secret')).toBeInTheDocument(); + }); + + it('shows ask_user_secret after the placeholder is stored', () => { + renderToolCallMessage([ + { + type: 'tool-call', + toolCallId: 'secret-done', + toolName: 'ask_user_secret', + args: {}, + argsText: '{}', + result: '{"data":[{"key":"api_key","placeholder":"tfy-ephemeral-secret://abc"}]}', + }, + ]); + + expect(screen.getByText('ask_user_secret')).toBeInTheDocument(); + }); + it('hides pending ask_user in live chat so the composer owns the prompt', () => { const AskUserPrompt = createAskUserPromptProbe(); diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 78772f65f2..c77a8ea5b5 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -8,11 +8,11 @@ import { EventType, SessionStoreConflictError, SessionStoreNotFoundError, - TurnResourceResolver, type SessionHandle, type TurnHandle, type TurnInputItem, type TurnRecordWithoutSnapshot, + type TurnResourceResolver, } from '@truefoundry/trueforge-core/agent-session'; import type { IWebSearchProvider } from '@truefoundry/trueforge-core/core'; import { @@ -38,7 +38,7 @@ import type { Logger } from 'winston'; import type { ResolveMcpServerStoreInput, ResolveModelProviderStoreInput, ResolveTurnSkillsStoreInput } from '../app'; import type { Authorizer } from '../auth/authorizer'; import type { ResolveRequestContext } from '../auth/identity'; -import configuration from '../config'; +import configuration, { isTrueFoundryModeEnabled } from '../config'; import type { AgentRecord, IAgentStore } from '../db/agentStore'; import type { IMcpServerWithAuthStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; @@ -70,6 +70,8 @@ import { import { DEFAULT_SANDBOX_ENVIRONMENT_NAME } from '../schemas/sandboxEnvironment'; import { MAX_SESSION_TITLE_LENGTH } from '../schemas/session'; import { assertGatewayMetadataRequestHeaders } from '../truefoundry/gatewayMetadata'; +import { assertTfgToolsHeaderAllowed, parseTfgTools, X_TFG_TOOLS, type TfgToolName } from '../truefoundry/tfgTools'; +import { TrueFoundryTurnResourceResolver } from '../truefoundry/TrueFoundryTurnResourceResolver'; import { newId } from '../utils/id'; import { resolveWebSearchProvider } from '../websearch/providers'; import { canReadAgentBoundResource, canReadSession } from './agentAccess'; @@ -158,6 +160,8 @@ interface BeginTurnExecutionParams { userRef: string; /** Raw inbound request headers. Absent for a schedule run. */ requestHeaders?: Record; + /** Tools from `x-tfg-tools`. Absent on schedule runs, which have no request headers. */ + enabledTools?: readonly TfgToolName[]; deps: BeginTurnExecutionDeps; } @@ -178,6 +182,7 @@ function createTurnResolver(deps: { userRef: string; session: SessionHandle; turnMetadata: TurnMetadata; + enabledTools: readonly TfgToolName[]; }): TurnResourceResolver { const { mcpServerStore, @@ -192,106 +197,110 @@ function createTurnResolver(deps: { userRef, session, turnMetadata, + enabledTools, } = deps; const tenant_id = session.tenant_id; const sessionId = session.session_id; - return new TurnResourceResolver({ - llm: async name => { - const resolved = await getModelDetails({ - tenant_id, - name, - store: modelProviderStore, - turnMetadata, - }); - return { - modelClient: new VercelAILLM({ - providerConfig: resolved.providerConfig, - logger, - signal, - }), - defaultModelParams: resolved.defaultModelParams, - modelProperties: resolved.modelProperties, - providerType: resolved.providerConfig.provider.type, - }; - }, - mcp: async name => { - const connection = await getMcpConnection({ - tenant_id, - name, - store: mcpServerStore, - userRef, - turnMetadata, - }); - if (connection === undefined) { - throw new HTTPException(422, { - message: `Unknown MCP server "${name}" — not configured`, + return new TrueFoundryTurnResourceResolver({ + enabledTools, + deps: { + llm: async name => { + const resolved = await getModelDetails({ + tenant_id, + name, + store: modelProviderStore, + turnMetadata, }); - } - return connection; - }, - mcpRequestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, - mcpConnectTimeoutMs: configuration.MCP_CONNECT_TIMEOUT_MS, - mcpMaxResponseBytes: configuration.MCP_TOOL_CALL_MAX_RESPONSE_BYTES, - sandboxProvider: async ({ spec, existingSandboxId, tracing }) => { - const provider = await resolveSandboxProvider({ - tenant_id, - store: sandboxProviderStore, - logger, - sessionId, - }); - if (provider === undefined) { - throw new HTTPException(422, { - message: 'no sandbox provider configured — PUT /settings/sandbox-providers', + return { + modelClient: new VercelAILLM({ + providerConfig: resolved.providerConfig, + logger, + signal, + }), + defaultModelParams: resolved.defaultModelParams, + modelProperties: resolved.modelProperties, + providerType: resolved.providerConfig.provider.type, + }; + }, + mcp: async name => { + const connection = await getMcpConnection({ + tenant_id, + name, + store: mcpServerStore, + userRef, + turnMetadata, }); - } - const skills = spec.skills ?? []; - const mountSkills = - skills.length === 0 - ? [] - : await skillStore.resolveTurnSkills({ - tenant_id, - skills, - }); - const carriedSandboxId = existingSandboxIdForProvider({ - existingSandboxId, - currentProviderType: provider.type, - }); - const turnSandboxBase = { - logger, - skills: mountSkills, - fileDownloadEnabled: spec.config.sandbox.file_downloads, - existingSandboxId: carriedSandboxId, - tracing, - }; - // Env-capable providers (Daytona): resolve env. Others skip (no snapshot concept). - if (provider.envSupported) { - const environment = await resolveSandboxEnvironment({ + if (connection === undefined) { + throw new HTTPException(422, { + message: `Unknown MCP server "${name}" — not configured`, + }); + } + return connection; + }, + mcpRequestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, + mcpConnectTimeoutMs: configuration.MCP_CONNECT_TIMEOUT_MS, + mcpMaxResponseBytes: configuration.MCP_TOOL_CALL_MAX_RESPONSE_BYTES, + sandboxProvider: async ({ spec, existingSandboxId, tracing }) => { + const provider = await resolveSandboxProvider({ tenant_id, - name: spec.config.sandbox.environment_name ?? DEFAULT_SANDBOX_ENVIRONMENT_NAME, - sandboxEnvironmentStore, - optional: spec.config.sandbox.environment_name === undefined, + store: sandboxProviderStore, + logger, + sessionId, + }); + if (provider === undefined) { + throw new HTTPException(422, { + message: 'no sandbox provider configured — PUT /settings/sandbox-providers', + }); + } + const skills = spec.skills ?? []; + const mountSkills = + skills.length === 0 + ? [] + : await skillStore.resolveTurnSkills({ + tenant_id, + skills, + }); + const carriedSandboxId = existingSandboxIdForProvider({ + existingSandboxId, + currentProviderType: provider.type, }); + const turnSandboxBase = { + logger, + skills: mountSkills, + fileDownloadEnabled: spec.config.sandbox.file_downloads, + existingSandboxId: carriedSandboxId, + tracing, + }; + // Env-capable providers (Daytona): resolve env. Others skip (no snapshot concept). + if (provider.envSupported) { + const environment = await resolveSandboxEnvironment({ + tenant_id, + name: spec.config.sandbox.environment_name ?? DEFAULT_SANDBOX_ENVIRONMENT_NAME, + sandboxEnvironmentStore, + optional: spec.config.sandbox.environment_name === undefined, + }); + return buildTurnSandbox({ + provider, + ...(environment !== undefined ? { environment } : {}), + ...turnSandboxBase, + }); + } return buildTurnSandbox({ provider, - ...(environment !== undefined ? { environment } : {}), ...turnSandboxBase, }); - } - return buildTurnSandbox({ - provider, - ...turnSandboxBase, - }); - }, - agent: async agentId => { - const record = await agentStore.getAgent({ tenant_id, id: agentId }); - if (record === undefined) { - throw new HTTPException(422, { message: `Agent not found: ${agentId}` }); - } - return record.manifest; + }, + agent: async agentId => { + const record = await agentStore.getAgent({ tenant_id, id: agentId }); + if (record === undefined) { + throw new HTTPException(422, { message: `Agent not found: ${agentId}` }); + } + return record.manifest; + }, + webSearchProvider, + logger, }, - webSearchProvider, - logger, }); } @@ -453,7 +462,7 @@ export interface TurnEventDrainInput { export async function beginTurnExecution( params: BeginTurnExecutionParams, ): Promise<{ turn: TurnHandle; drainInput: TurnEventDrainInput }> { - const { session, input, previous_turn_id: previousTurnId, userRef, requestHeaders, deps } = params; + const { session, input, previous_turn_id: previousTurnId, userRef, requestHeaders, enabledTools, deps } = params; // Fail closed on a bad inbound header before minting a turn id / starting execution. assertGatewayMetadataRequestHeaders(requestHeaders); const sessionId = session.session_id; @@ -490,6 +499,7 @@ export async function beginTurnExecution( ...(sessionAgent.type === 'reference' ? { agent: { id: sessionAgent.id, name: sessionAgent.name } } : {}), ...(requestHeaders === undefined ? {} : { requestHeaders }), }, + enabledTools: enabledTools ?? [], }); // First turn only: derive the title from the first user message. The store @@ -846,12 +856,19 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { // x-tfg-skills / x-tfg-mcp / x-tfg-models on turns only when the session agent is inline. const allowInlineResources = session.record.agent.type === 'inline'; + // x-tfg-tools is per turn and is not stored. A resume must send it again. + const rawTfgTools = c.req.header(X_TFG_TOOLS); + if (rawTfgTools !== undefined) { + assertTfgToolsHeaderAllowed({ isTrueFoundryMode: isTrueFoundryModeEnabled(configuration) }); + } + const enabledTools = rawTfgTools === undefined ? undefined : parseTfgTools(rawTfgTools); const turnParams: BeginTurnExecutionParams = { session, input: body.input, previous_turn_id: body.previous_turn_id, userRef: requestContext.subject.id, requestHeaders: c.req.header(), + ...(enabledTools === undefined ? {} : { enabledTools }), deps: { ...deps, modelProviderStore: deps.resolveModelProviderStore({ diff --git a/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts b/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts new file mode 100644 index 0000000000..5120c8f4af --- /dev/null +++ b/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts @@ -0,0 +1,25 @@ +import { TurnResourceResolver } from '@truefoundry/trueforge-core/agent-session'; +import { ASK_USER_SECRET_TOOL_NAME, askUserSecret, type TfgToolName } from './tfgTools'; + +type TurnResolverDeps = ConstructorParameters[0]; + +/** Root-only mount for `x-tfg-tools`. A child thread passes `agent_info` and does not get the tool. */ +export class TrueFoundryTurnResourceResolver extends TurnResourceResolver { + readonly #enabledTools: ReadonlySet; + + constructor(input: { deps: TurnResolverDeps; enabledTools: readonly TfgToolName[] }) { + super(input.deps); + this.#enabledTools = new Set(input.enabledTools); + } + + override async resolveAgentDefinition(input: Parameters[0]) { + const resolved = await super.resolveAgentDefinition(input); + if (input.agent_info !== undefined || !this.#enabledTools.has(ASK_USER_SECRET_TOOL_NAME)) { + return resolved; + } + return { + ...resolved, + extraCapabilities: [...(resolved.extraCapabilities ?? []), askUserSecret()], + }; + } +} diff --git a/packages/trueforge/src/truefoundry/tfgTools.ts b/packages/trueforge/src/truefoundry/tfgTools.ts new file mode 100644 index 0000000000..c6c378fb87 --- /dev/null +++ b/packages/trueforge/src/truefoundry/tfgTools.ts @@ -0,0 +1,120 @@ +import { + ClientSideTool, + defineTool, + toolResultResponse, + type AgentCapability, + type ToolDefinition, +} from '@truefoundry/trueforge-core/core'; +import { HTTPException } from 'hono/http-exception'; +import { z } from 'zod'; + +/** Per-turn tool names. Not stored, so a resume turn must send the header again. */ +export const X_TFG_TOOLS = 'x-tfg-tools'; + +const ASK_USER_SECRET_SERVER_ID = 'ask-user-secret'; +export const ASK_USER_SECRET_TOOL_NAME = 'ask_user_secret'; + +const TfgToolNameSchema = z.enum([ASK_USER_SECRET_TOOL_NAME]); + +export type TfgToolName = z.infer; + +/** Reject the header outside TrueFoundry mode before parsing, so a bad payload does not hide the mode error. */ +export function assertTfgToolsHeaderAllowed(input: { isTrueFoundryMode: boolean }): void { + if (!input.isTrueFoundryMode) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} requires TrueFoundry mode` }); + } +} + +/** + * Names from `x-tfg-tools` for this turn. Not stored, so a resume must send the header again. + * Rejects a bad value rather than mounting nothing. + */ +export function parseTfgTools(raw: string): readonly TfgToolName[] { + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch (error) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array`, cause: error }); + } + if (!Array.isArray(decoded)) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array of tool names` }); + } + + const names: TfgToolName[] = []; + const seen = new Set(); + for (const entry of decoded) { + if (typeof entry !== 'string') { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} entries must be tool names` }); + } + const parsed = TfgToolNameSchema.safeParse(entry); + if (!parsed.success) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} entry "${entry}" is not a supported tool` }); + } + if (!seen.has(parsed.data)) { + seen.add(parsed.data); + names.push(parsed.data); + } + } + return names; +} + +const AskUserSecretFieldSchema = z.object({ + key: z.string().describe('Unique identifier for this field, used as the key in the response mapping.'), + label: z.string().describe('Human-readable label shown to the user for this input field.'), + required: z.boolean().default(true).describe('Whether the user must provide this field. Defaults to true.'), +}); + +const askUserSecretInputSchema = z.object({ + question: z.string().describe('A short prompt explaining what secret data is needed and why.'), + fields: z.array(AskUserSecretFieldSchema).min(1).max(10).describe('The secret fields to collect from the user.'), +}); + +/** Client stores the secret and returns a `tfy-ephemeral-secret://` placeholder. The value is not sent here. */ +export class AskUserSecret extends ClientSideTool { + readonly name = ASK_USER_SECRET_SERVER_ID; + readonly displayName = 'AskUserSecret'; + override readonly tracingEnabled = false; + + protected getTools(): ToolDefinition[] { + return [ + defineTool({ + name: ASK_USER_SECRET_TOOL_NAME, + description: [ + 'Collect secrets from the user via a secure masked form. Returns opaque tfy-ephemeral-secret:// placeholders that only apply_manifest can dereference.', + '', + 'A secret is a value that grants access: passwords, API keys, tokens, client secrets, private keys, connection strings.', + 'Public metadata (project names, regions, endpoint URLs, display names) is NOT a secret. Use ask_user_question for those.', + '', + 'Rules:', + '- Never fabricate placeholder values. Only use exactly what this tool returns.', + '- Placeholders work ONLY inside apply_manifest. Do not pass them to any other tool.', + '- Optional fields left blank by the user are omitted from the response.', + '', + 'Example:', + 'question: "Provide your OAuth credentials for the AWS integration."', + 'fields: [{"key": "client_id", "label": "Client ID", "required": true}, {"key": "client_secret", "label": "Client Secret", "required": true}]', + '', + 'Response:', + '{"data": [{"key": "client_id", "placeholder": "tfy-ephemeral-secret://clx9abc123"}, {"key": "client_secret", "placeholder": "tfy-ephemeral-secret://clx9def456"}]}', + '', + 'Then pass the placeholders to apply_manifest:', + '{"manifest": {"type": "cloud-integration", "auth_data": {"client_id": "tfy-ephemeral-secret://clx9abc123", "client_secret": "tfy-ephemeral-secret://clx9def456"}}}', + ].join('\n'), + schema: askUserSecretInputSchema, + handler: () => + Promise.resolve( + toolResultResponse({ + text: 'This tool requires client-side execution and cannot be run server-side.', + isError: true, + }), + ), + }), + ]; + } +} + +export function askUserSecret(): AgentCapability { + return { + systemToolSets: [new AskUserSecret()], + }; +} diff --git a/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts b/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts new file mode 100644 index 0000000000..17692190bd --- /dev/null +++ b/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts @@ -0,0 +1,60 @@ +import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; +import type { AgentInfo, ILLM } from '@truefoundry/trueforge-core/core'; +import { createLogger } from 'winston'; +import { TrueFoundryTurnResourceResolver } from '../../../src/truefoundry/TrueFoundryTurnResourceResolver'; +import type { TfgToolName } from '../../../src/truefoundry/tfgTools'; + +const unusedLlm: ILLM = { + create: () => { + throw new Error('unused'); + }, + createNonStream: () => Promise.reject(new Error('unused')), +}; + +function resolverWith(enabledTools: readonly TfgToolName[]): TrueFoundryTurnResourceResolver { + return new TrueFoundryTurnResourceResolver({ + enabledTools, + deps: { + llm: () => + Promise.resolve({ + modelClient: unusedLlm, + defaultModelParams: {}, + providerType: undefined, + }), + mcp: () => Promise.reject(new Error('unused')), + mcpRequestTimeoutMs: 1000, + mcpConnectTimeoutMs: 1000, + logger: createLogger({ silent: true }), + }, + }); +} + +const spec = AgentSpecSchema.parse({ model: { name: 'provider/model' } }); +const child: AgentInfo = { type: 'dynamic', name: 'child', input: 'do the task' }; + +describe('TrueFoundryTurnResourceResolver', () => { + it('mounts ask_user_secret on the root thread only', async () => { + const resolver = resolverWith(['ask_user_secret']); + const tracing = resolver.createTracing(); + const signal = new AbortController().signal; + + const root = await resolver.resolveAgentDefinition({ spec, signal, tracing }); + const nested = await resolver.resolveAgentDefinition({ spec, signal, tracing, agent_info: child }); + + expect( + root.extraCapabilities?.flatMap(capability => capability.systemToolSets ?? []).map(toolSet => toolSet.name), + ).toEqual(['ask-user-secret']); + expect(nested.extraCapabilities).toBeUndefined(); + }); + + it('mounts nothing when the header listed no tools', async () => { + const resolver = resolverWith([]); + const resolved = await resolver.resolveAgentDefinition({ + spec, + signal: new AbortController().signal, + tracing: resolver.createTracing(), + }); + + expect(resolved.extraCapabilities).toBeUndefined(); + }); +}); diff --git a/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts new file mode 100644 index 0000000000..22c687b9bf --- /dev/null +++ b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts @@ -0,0 +1,73 @@ +import { HTTPException } from 'hono/http-exception'; +import { + ASK_USER_SECRET_TOOL_NAME, + AskUserSecret, + assertTfgToolsHeaderAllowed, + parseTfgTools, + X_TFG_TOOLS, +} from '../../../src/truefoundry/tfgTools'; + +describe('assertTfgToolsHeaderAllowed', () => { + it('rejects the header outside TrueFoundry mode', () => { + expect(() => assertTfgToolsHeaderAllowed({ isTrueFoundryMode: false })).toThrow( + expect.objectContaining({ message: `${X_TFG_TOOLS} requires TrueFoundry mode` }), + ); + }); + + it('allows the header in TrueFoundry mode', () => { + expect(() => assertTfgToolsHeaderAllowed({ isTrueFoundryMode: true })).not.toThrow(); + }); +}); + +describe('parseTfgTools', () => { + it('parses ask_user_secret and drops a repeated name', () => { + expect(parseTfgTools(JSON.stringify(['ask_user_secret', 'ask_user_secret']))).toEqual(['ask_user_secret']); + }); + + it('accepts an empty list', () => { + expect(parseTfgTools('[]')).toEqual([]); + }); + + it.each([ + ['not json', 'not-json', `${X_TFG_TOOLS} must be a JSON array`], + ['an object', '{}', `${X_TFG_TOOLS} must be a JSON array of tool names`], + ['a non-string entry', '[1]', `${X_TFG_TOOLS} entries must be tool names`], + ['an unknown tool', '["nope"]', `${X_TFG_TOOLS} entry "nope" is not a supported tool`], + ])('rejects %s', (_case, raw, message) => { + expect(() => parseTfgTools(raw)).toThrow(expect.objectContaining({ message })); + }); + + it('keeps a bad JSON parse as the cause', () => { + expect(() => parseTfgTools('not-json')).toThrow(expect.objectContaining({ cause: expect.any(SyntaxError) })); + }); + + it('rejects with HTTPException', () => { + expect(() => parseTfgTools('{}')).toThrow(HTTPException); + }); +}); + +describe('AskUserSecret', () => { + const tool = new AskUserSecret(); + + it('advertises ask_user_secret as a client-side system tool', async () => { + const listed = await tool.listTools(); + if (!('result' in listed)) { + throw new Error('expected a tool list'); + } + expect(listed.result.tools.map(entry => entry.name)).toEqual([ASK_USER_SECRET_TOOL_NAME]); + expect(listed.result.tools[0]?.description).toContain('tfy-ephemeral-secret://'); + expect(listed.result.tools[0]?.description).toContain('apply_manifest'); + + const response = await tool.callTool({ name: ASK_USER_SECRET_TOOL_NAME, arguments: {} }); + if (!('clientSideToolRequired' in response)) { + throw new Error('expected a client-side tool'); + } + expect(response.clientSideToolRequired.tool_info).toEqual({ + type: 'truefoundry-system', + original_tool_name: ASK_USER_SECRET_TOOL_NAME, + mcp_server_id: 'ask-user-secret', + mcp_server_name: 'ask-user-secret', + is_client_side: true, + }); + }); +}); From 88bd99e7e5f42632db0a5c1d169719b3b7fcda7d Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Wed, 28 Oct 2026 14:25:12 +0530 Subject: [PATCH 2/9] feat: accept tool definitions in x-tfg-tools Callers send the name, description, and JSON schema for each client-side tool. The harness mounts that list on the root agent for the turn. Signed-off-by: Raman Tehlan --- packages/trueforge/src/apis/turns.ts | 25 +- .../TrueFoundryTurnResourceResolver.ts | 14 +- .../trueforge/src/truefoundry/tfgTools.ts | 236 ++++++++++++------ .../TrueFoundryTurnResourceResolver.test.ts | 22 +- .../tests/unit/truefoundry/tfgTools.test.ts | 90 +++++-- 5 files changed, 270 insertions(+), 117 deletions(-) diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index c77a8ea5b5..6895abc71b 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -70,7 +70,12 @@ import { import { DEFAULT_SANDBOX_ENVIRONMENT_NAME } from '../schemas/sandboxEnvironment'; import { MAX_SESSION_TITLE_LENGTH } from '../schemas/session'; import { assertGatewayMetadataRequestHeaders } from '../truefoundry/gatewayMetadata'; -import { assertTfgToolsHeaderAllowed, parseTfgTools, X_TFG_TOOLS, type TfgToolName } from '../truefoundry/tfgTools'; +import { + assertTfgToolsHeaderAllowed, + parseTfgTools, + X_TFG_TOOLS, + type TfgToolDefinition, +} from '../truefoundry/tfgTools'; import { TrueFoundryTurnResourceResolver } from '../truefoundry/TrueFoundryTurnResourceResolver'; import { newId } from '../utils/id'; import { resolveWebSearchProvider } from '../websearch/providers'; @@ -160,8 +165,8 @@ interface BeginTurnExecutionParams { userRef: string; /** Raw inbound request headers. Absent for a schedule run. */ requestHeaders?: Record; - /** Tools from `x-tfg-tools`. Absent on schedule runs, which have no request headers. */ - enabledTools?: readonly TfgToolName[]; + /** Tool definitions from `x-tfg-tools`. Absent on schedule runs, which have no request headers. */ + tfgTools?: readonly TfgToolDefinition[]; deps: BeginTurnExecutionDeps; } @@ -182,7 +187,7 @@ function createTurnResolver(deps: { userRef: string; session: SessionHandle; turnMetadata: TurnMetadata; - enabledTools: readonly TfgToolName[]; + tfgTools: readonly TfgToolDefinition[]; }): TurnResourceResolver { const { mcpServerStore, @@ -197,13 +202,13 @@ function createTurnResolver(deps: { userRef, session, turnMetadata, - enabledTools, + tfgTools, } = deps; const tenant_id = session.tenant_id; const sessionId = session.session_id; return new TrueFoundryTurnResourceResolver({ - enabledTools, + tools: tfgTools, deps: { llm: async name => { const resolved = await getModelDetails({ @@ -462,7 +467,7 @@ export interface TurnEventDrainInput { export async function beginTurnExecution( params: BeginTurnExecutionParams, ): Promise<{ turn: TurnHandle; drainInput: TurnEventDrainInput }> { - const { session, input, previous_turn_id: previousTurnId, userRef, requestHeaders, enabledTools, deps } = params; + const { session, input, previous_turn_id: previousTurnId, userRef, requestHeaders, tfgTools, deps } = params; // Fail closed on a bad inbound header before minting a turn id / starting execution. assertGatewayMetadataRequestHeaders(requestHeaders); const sessionId = session.session_id; @@ -499,7 +504,7 @@ export async function beginTurnExecution( ...(sessionAgent.type === 'reference' ? { agent: { id: sessionAgent.id, name: sessionAgent.name } } : {}), ...(requestHeaders === undefined ? {} : { requestHeaders }), }, - enabledTools: enabledTools ?? [], + tfgTools: tfgTools ?? [], }); // First turn only: derive the title from the first user message. The store @@ -861,14 +866,14 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { if (rawTfgTools !== undefined) { assertTfgToolsHeaderAllowed({ isTrueFoundryMode: isTrueFoundryModeEnabled(configuration) }); } - const enabledTools = rawTfgTools === undefined ? undefined : parseTfgTools(rawTfgTools); + const tfgTools = rawTfgTools === undefined ? undefined : parseTfgTools(rawTfgTools); const turnParams: BeginTurnExecutionParams = { session, input: body.input, previous_turn_id: body.previous_turn_id, userRef: requestContext.subject.id, requestHeaders: c.req.header(), - ...(enabledTools === undefined ? {} : { enabledTools }), + ...(tfgTools === undefined ? {} : { tfgTools }), deps: { ...deps, modelProviderStore: deps.resolveModelProviderStore({ diff --git a/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts b/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts index 5120c8f4af..84a6f8bf76 100644 --- a/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts +++ b/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts @@ -1,25 +1,25 @@ import { TurnResourceResolver } from '@truefoundry/trueforge-core/agent-session'; -import { ASK_USER_SECRET_TOOL_NAME, askUserSecret, type TfgToolName } from './tfgTools'; +import { tfgToolsCapability, type TfgToolDefinition } from './tfgTools'; type TurnResolverDeps = ConstructorParameters[0]; -/** Root-only mount for `x-tfg-tools`. A child thread passes `agent_info` and does not get the tool. */ +/** Root-only mount for `x-tfg-tools`. A child thread passes `agent_info` and does not get the tools. */ export class TrueFoundryTurnResourceResolver extends TurnResourceResolver { - readonly #enabledTools: ReadonlySet; + readonly #tools: readonly TfgToolDefinition[]; - constructor(input: { deps: TurnResolverDeps; enabledTools: readonly TfgToolName[] }) { + constructor(input: { deps: TurnResolverDeps; tools: readonly TfgToolDefinition[] }) { super(input.deps); - this.#enabledTools = new Set(input.enabledTools); + this.#tools = input.tools; } override async resolveAgentDefinition(input: Parameters[0]) { const resolved = await super.resolveAgentDefinition(input); - if (input.agent_info !== undefined || !this.#enabledTools.has(ASK_USER_SECRET_TOOL_NAME)) { + if (input.agent_info !== undefined || this.#tools.length === 0) { return resolved; } return { ...resolved, - extraCapabilities: [...(resolved.extraCapabilities ?? []), askUserSecret()], + extraCapabilities: [...(resolved.extraCapabilities ?? []), tfgToolsCapability(this.#tools)], }; } } diff --git a/packages/trueforge/src/truefoundry/tfgTools.ts b/packages/trueforge/src/truefoundry/tfgTools.ts index c6c378fb87..f7e194d6ed 100644 --- a/packages/trueforge/src/truefoundry/tfgTools.ts +++ b/packages/trueforge/src/truefoundry/tfgTools.ts @@ -1,22 +1,51 @@ import { ClientSideTool, - defineTool, toolResultResponse, type AgentCapability, type ToolDefinition, } from '@truefoundry/trueforge-core/core'; import { HTTPException } from 'hono/http-exception'; -import { z } from 'zod'; -/** Per-turn tool names. Not stored, so a resume turn must send the header again. */ +/** Per-turn tool definitions. Not stored, so a resume turn must send the header again. */ export const X_TFG_TOOLS = 'x-tfg-tools'; -const ASK_USER_SECRET_SERVER_ID = 'ask-user-secret'; -export const ASK_USER_SECRET_TOOL_NAME = 'ask_user_secret'; +const TFG_TOOLS_SERVER_ID = 'tfg-tools'; +const MAX_TOOLS = 16; +const MAX_DESCRIPTION_LENGTH = 4_000; +const MAX_HEADER_LENGTH = 16_000; +const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; -const TfgToolNameSchema = z.enum([ASK_USER_SECRET_TOOL_NAME]); +/** + * Harness system tools register first and keep their names. A header tool with one of these + * names would be renamed, so the caller and the UI would no longer find it. + */ +const RESERVED_TOOL_NAMES = new Set([ + 'ask_user_question', + 'call_tool', + 'create_sub_agent', + 'exec', + 'get_current_datetime', + 'get_openui_instructions', + 'get_tool_info', + 'get_tool_output_schema', + 'list_tools', + 'unknown', + 'web_fetch', + 'web_search', +]); -export type TfgToolName = z.infer; +export interface TfgToolParameters { + type: 'object'; + properties?: Record; + required?: string[]; + [key: string]: unknown; +} + +export interface TfgToolDefinition { + name: string; + description: string; + parameters: TfgToolParameters; +} /** Reject the header outside TrueFoundry mode before parsing, so a bad payload does not hide the mode error. */ export function assertTfgToolsHeaderAllowed(input: { isTrueFoundryMode: boolean }): void { @@ -26,10 +55,13 @@ export function assertTfgToolsHeaderAllowed(input: { isTrueFoundryMode: boolean } /** - * Names from `x-tfg-tools` for this turn. Not stored, so a resume must send the header again. - * Rejects a bad value rather than mounting nothing. + * Tool definitions from `x-tfg-tools` for this turn. Not stored, so a resume must send the header again. + * Rejects a bad value rather than mounting a partial list. */ -export function parseTfgTools(raw: string): readonly TfgToolName[] { +export function parseTfgTools(raw: string): readonly TfgToolDefinition[] { + if (raw.length > MAX_HEADER_LENGTH) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} is too large` }); + } let decoded: unknown; try { decoded = JSON.parse(raw); @@ -37,84 +69,136 @@ export function parseTfgTools(raw: string): readonly TfgToolName[] { throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array`, cause: error }); } if (!Array.isArray(decoded)) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array of tool names` }); + throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array of tool definitions` }); + } + if (decoded.length > MAX_TOOLS) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} lists more than ${String(MAX_TOOLS)} tools` }); } - const names: TfgToolName[] = []; - const seen = new Set(); - for (const entry of decoded) { - if (typeof entry !== 'string') { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} entries must be tool names` }); - } - const parsed = TfgToolNameSchema.safeParse(entry); - if (!parsed.success) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} entry "${entry}" is not a supported tool` }); - } - if (!seen.has(parsed.data)) { - seen.add(parsed.data); - names.push(parsed.data); + const tools: TfgToolDefinition[] = []; + const seen = new Set(); + for (let index = 0; index < decoded.length; index++) { + const tool = parseToolDefinition(decoded[index], index); + if (seen.has(tool.name)) { + throw new HTTPException(400, { message: entryMessage(index, `name "${tool.name}" is listed more than once`) }); } + seen.add(tool.name); + tools.push(tool); } - return names; + return tools; +} + +export function tfgToolsCapability(tools: readonly TfgToolDefinition[]): AgentCapability { + return { + systemToolSets: [new HeaderClientSideTools(tools)], + }; } -const AskUserSecretFieldSchema = z.object({ - key: z.string().describe('Unique identifier for this field, used as the key in the response mapping.'), - label: z.string().describe('Human-readable label shown to the user for this input field.'), - required: z.boolean().default(true).describe('Whether the user must provide this field. Defaults to true.'), -}); - -const askUserSecretInputSchema = z.object({ - question: z.string().describe('A short prompt explaining what secret data is needed and why.'), - fields: z.array(AskUserSecretFieldSchema).min(1).max(10).describe('The secret fields to collect from the user.'), -}); - -/** Client stores the secret and returns a `tfy-ephemeral-secret://` placeholder. The value is not sent here. */ -export class AskUserSecret extends ClientSideTool { - readonly name = ASK_USER_SECRET_SERVER_ID; - readonly displayName = 'AskUserSecret'; +class HeaderClientSideTools extends ClientSideTool { + readonly name = TFG_TOOLS_SERVER_ID; + readonly displayName = 'TfgTools'; override readonly tracingEnabled = false; + readonly #tools: ToolDefinition[]; + + constructor(tools: readonly TfgToolDefinition[]) { + super(); + this.#tools = tools.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.parameters, + execute: () => + Promise.resolve( + toolResultResponse({ + text: 'This tool requires client-side execution and cannot be run server-side.', + isError: true, + }), + ), + })); + } protected getTools(): ToolDefinition[] { - return [ - defineTool({ - name: ASK_USER_SECRET_TOOL_NAME, - description: [ - 'Collect secrets from the user via a secure masked form. Returns opaque tfy-ephemeral-secret:// placeholders that only apply_manifest can dereference.', - '', - 'A secret is a value that grants access: passwords, API keys, tokens, client secrets, private keys, connection strings.', - 'Public metadata (project names, regions, endpoint URLs, display names) is NOT a secret. Use ask_user_question for those.', - '', - 'Rules:', - '- Never fabricate placeholder values. Only use exactly what this tool returns.', - '- Placeholders work ONLY inside apply_manifest. Do not pass them to any other tool.', - '- Optional fields left blank by the user are omitted from the response.', - '', - 'Example:', - 'question: "Provide your OAuth credentials for the AWS integration."', - 'fields: [{"key": "client_id", "label": "Client ID", "required": true}, {"key": "client_secret", "label": "Client Secret", "required": true}]', - '', - 'Response:', - '{"data": [{"key": "client_id", "placeholder": "tfy-ephemeral-secret://clx9abc123"}, {"key": "client_secret", "placeholder": "tfy-ephemeral-secret://clx9def456"}]}', - '', - 'Then pass the placeholders to apply_manifest:', - '{"manifest": {"type": "cloud-integration", "auth_data": {"client_id": "tfy-ephemeral-secret://clx9abc123", "client_secret": "tfy-ephemeral-secret://clx9def456"}}}', - ].join('\n'), - schema: askUserSecretInputSchema, - handler: () => - Promise.resolve( - toolResultResponse({ - text: 'This tool requires client-side execution and cannot be run server-side.', - isError: true, - }), - ), - }), - ]; + return this.#tools; } } -export function askUserSecret(): AgentCapability { +function parseToolDefinition(value: unknown, index: number): TfgToolDefinition { + if (!isPlainObject(value)) { + throw new HTTPException(400, { message: entryMessage(index, 'must be an object') }); + } + const name = value['name']; + if (typeof name !== 'string' || !TOOL_NAME_PATTERN.test(name)) { + throw new HTTPException(400, { + message: entryMessage(index, 'name must be 1-64 characters of letters, numbers, "_" or "-"'), + }); + } + if (RESERVED_TOOL_NAMES.has(name)) { + throw new HTTPException(400, { message: entryMessage(index, `name "${name}" is reserved`) }); + } + const description = value['description']; + if (typeof description !== 'string') { + throw new HTTPException(400, { message: entryMessage(index, 'description must be a non-empty string') }); + } + const trimmedDescription = description.trim(); + if (trimmedDescription.length === 0) { + throw new HTTPException(400, { message: entryMessage(index, 'description must be a non-empty string') }); + } + if (trimmedDescription.length > MAX_DESCRIPTION_LENGTH) { + throw new HTTPException(400, { + message: entryMessage(index, `description must be at most ${String(MAX_DESCRIPTION_LENGTH)} characters`), + }); + } return { - systemToolSets: [new AskUserSecret()], + name, + description: trimmedDescription, + parameters: parseParameters(value['parameters'], index), }; } + +function parseParameters(value: unknown, index: number): TfgToolParameters { + if (!isPlainObject(value) || value['type'] !== 'object') { + throw new HTTPException(400, { message: entryMessage(index, 'parameters must be a JSON object schema') }); + } + const parameters: TfgToolParameters = { type: 'object' }; + const properties = value['properties']; + if (properties !== undefined) { + if (!isPropertyMap(properties)) { + throw new HTTPException(400, { message: entryMessage(index, 'parameters.properties must be an object') }); + } + parameters.properties = properties; + } + const required = value['required']; + if (required !== undefined) { + if (!isStringArray(required)) { + throw new HTTPException(400, { + message: entryMessage(index, 'parameters.required must be an array of strings'), + }); + } + parameters.required = required; + } + for (const [key, entry] of Object.entries(value)) { + if (key === 'type' || key === 'properties' || key === 'required') { + continue; + } + parameters[key] = entry; + } + return parameters; +} + +function entryMessage(index: number, detail: string): string { + return `${X_TFG_TOOLS}[${String(index)}] ${detail}`; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isPropertyMap(value: unknown): value is Record { + if (!isPlainObject(value)) { + return false; + } + return Object.values(value).every(entry => isPlainObject(entry)); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every(entry => typeof entry === 'string'); +} diff --git a/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts b/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts index 17692190bd..bd563b7ad1 100644 --- a/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts @@ -2,7 +2,7 @@ import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; import type { AgentInfo, ILLM } from '@truefoundry/trueforge-core/core'; import { createLogger } from 'winston'; import { TrueFoundryTurnResourceResolver } from '../../../src/truefoundry/TrueFoundryTurnResourceResolver'; -import type { TfgToolName } from '../../../src/truefoundry/tfgTools'; +import { parseTfgTools, type TfgToolDefinition } from '../../../src/truefoundry/tfgTools'; const unusedLlm: ILLM = { create: () => { @@ -11,9 +11,19 @@ const unusedLlm: ILLM = { createNonStream: () => Promise.reject(new Error('unused')), }; -function resolverWith(enabledTools: readonly TfgToolName[]): TrueFoundryTurnResourceResolver { +const askUserSecret = parseTfgTools( + JSON.stringify([ + { + name: 'ask_user_secret', + description: 'Collect a secret.', + parameters: { type: 'object', properties: { question: { type: 'string' } }, required: ['question'] }, + }, + ]), +); + +function resolverWith(tools: readonly TfgToolDefinition[]): TrueFoundryTurnResourceResolver { return new TrueFoundryTurnResourceResolver({ - enabledTools, + tools, deps: { llm: () => Promise.resolve({ @@ -33,8 +43,8 @@ const spec = AgentSpecSchema.parse({ model: { name: 'provider/model' } }); const child: AgentInfo = { type: 'dynamic', name: 'child', input: 'do the task' }; describe('TrueFoundryTurnResourceResolver', () => { - it('mounts ask_user_secret on the root thread only', async () => { - const resolver = resolverWith(['ask_user_secret']); + it('mounts header tools on the root thread only', async () => { + const resolver = resolverWith(askUserSecret); const tracing = resolver.createTracing(); const signal = new AbortController().signal; @@ -43,7 +53,7 @@ describe('TrueFoundryTurnResourceResolver', () => { expect( root.extraCapabilities?.flatMap(capability => capability.systemToolSets ?? []).map(toolSet => toolSet.name), - ).toEqual(['ask-user-secret']); + ).toEqual(['tfg-tools']); expect(nested.extraCapabilities).toBeUndefined(); }); diff --git a/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts index 22c687b9bf..9251aa4d7e 100644 --- a/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts @@ -1,12 +1,25 @@ import { HTTPException } from 'hono/http-exception'; import { - ASK_USER_SECRET_TOOL_NAME, - AskUserSecret, assertTfgToolsHeaderAllowed, parseTfgTools, + tfgToolsCapability, X_TFG_TOOLS, } from '../../../src/truefoundry/tfgTools'; +const askUserSecret = { + name: 'ask_user_secret', + description: 'Collect a secret and return a tfy-ephemeral-secret:// placeholder for apply_manifest.', + parameters: { + type: 'object', + properties: { + question: { type: 'string' }, + fields: { type: 'array' }, + }, + required: ['question', 'fields'], + additionalProperties: false, + }, +}; + describe('assertTfgToolsHeaderAllowed', () => { it('rejects the header outside TrueFoundry mode', () => { expect(() => assertTfgToolsHeaderAllowed({ isTrueFoundryMode: false })).toThrow( @@ -20,8 +33,8 @@ describe('assertTfgToolsHeaderAllowed', () => { }); describe('parseTfgTools', () => { - it('parses ask_user_secret and drops a repeated name', () => { - expect(parseTfgTools(JSON.stringify(['ask_user_secret', 'ask_user_secret']))).toEqual(['ask_user_secret']); + it('parses a tool definition and keeps extra parameter keys', () => { + expect(parseTfgTools(JSON.stringify([askUserSecret]))).toEqual([askUserSecret]); }); it('accepts an empty list', () => { @@ -30,13 +43,48 @@ describe('parseTfgTools', () => { it.each([ ['not json', 'not-json', `${X_TFG_TOOLS} must be a JSON array`], - ['an object', '{}', `${X_TFG_TOOLS} must be a JSON array of tool names`], - ['a non-string entry', '[1]', `${X_TFG_TOOLS} entries must be tool names`], - ['an unknown tool', '["nope"]', `${X_TFG_TOOLS} entry "nope" is not a supported tool`], + ['an object', '{}', `${X_TFG_TOOLS} must be a JSON array of tool definitions`], + ['a string entry', '["ask_user_secret"]', `${X_TFG_TOOLS}[0] must be an object`], + [ + 'a reserved name', + JSON.stringify([{ ...askUserSecret, name: 'ask_user_question' }]), + `${X_TFG_TOOLS}[0] name "ask_user_question" is reserved`, + ], + [ + 'a bad name', + JSON.stringify([{ ...askUserSecret, name: 'has space' }]), + `${X_TFG_TOOLS}[0] name must be 1-64 characters of letters, numbers, "_" or "-"`, + ], + [ + 'an empty description', + JSON.stringify([{ ...askUserSecret, description: ' ' }]), + `${X_TFG_TOOLS}[0] description must be a non-empty string`, + ], + [ + 'parameters that are not an object schema', + JSON.stringify([{ ...askUserSecret, parameters: { type: 'string' } }]), + `${X_TFG_TOOLS}[0] parameters must be a JSON object schema`, + ], + [ + 'a duplicate name', + JSON.stringify([askUserSecret, askUserSecret]), + `${X_TFG_TOOLS}[1] name "ask_user_secret" is listed more than once`, + ], + [ + 'too many tools', + JSON.stringify(Array.from({ length: 17 }, (_, index) => ({ ...askUserSecret, name: `tool_${String(index)}` }))), + `${X_TFG_TOOLS} lists more than 16 tools`, + ], ])('rejects %s', (_case, raw, message) => { expect(() => parseTfgTools(raw)).toThrow(expect.objectContaining({ message })); }); + it('rejects a header that is too large', () => { + expect(() => parseTfgTools('x'.repeat(16_001))).toThrow( + expect.objectContaining({ message: `${X_TFG_TOOLS} is too large` }), + ); + }); + it('keeps a bad JSON parse as the cause', () => { expect(() => parseTfgTools('not-json')).toThrow(expect.objectContaining({ cause: expect.any(SyntaxError) })); }); @@ -46,27 +94,33 @@ describe('parseTfgTools', () => { }); }); -describe('AskUserSecret', () => { - const tool = new AskUserSecret(); - - it('advertises ask_user_secret as a client-side system tool', async () => { - const listed = await tool.listTools(); +describe('tfgToolsCapability', () => { + it('advertises the header tool as a client-side system tool', async () => { + const [toolSet] = tfgToolsCapability(parseTfgTools(JSON.stringify([askUserSecret]))).systemToolSets ?? []; + if (toolSet === undefined) { + throw new Error('expected a tool set'); + } + const listed = await toolSet.listTools(); if (!('result' in listed)) { throw new Error('expected a tool list'); } - expect(listed.result.tools.map(entry => entry.name)).toEqual([ASK_USER_SECRET_TOOL_NAME]); + expect(listed.result.tools.map(entry => entry.name)).toEqual(['ask_user_secret']); expect(listed.result.tools[0]?.description).toContain('tfy-ephemeral-secret://'); - expect(listed.result.tools[0]?.description).toContain('apply_manifest'); + expect(listed.result.tools[0]?.inputSchema).toMatchObject({ + type: 'object', + required: ['question', 'fields'], + additionalProperties: false, + }); - const response = await tool.callTool({ name: ASK_USER_SECRET_TOOL_NAME, arguments: {} }); + const response = await toolSet.callTool({ name: 'ask_user_secret', arguments: {} }); if (!('clientSideToolRequired' in response)) { throw new Error('expected a client-side tool'); } expect(response.clientSideToolRequired.tool_info).toEqual({ type: 'truefoundry-system', - original_tool_name: ASK_USER_SECRET_TOOL_NAME, - mcp_server_id: 'ask-user-secret', - mcp_server_name: 'ask-user-secret', + original_tool_name: 'ask_user_secret', + mcp_server_id: 'tfg-tools', + mcp_server_name: 'tfg-tools', is_client_side: true, }); }); From 607cd6c0235102a3f5692191e5f9fe4d4dbf7bc4 Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Wed, 28 Oct 2026 14:25:13 +0530 Subject: [PATCH 3/9] fix: keep a typed secret out of the tool result Without a host secret form, the composer offered a plaintext answer and stored it as the ask_user_secret result. Signed-off-by: Raman Tehlan --- .changeset/x-tfg-tools-ask-user-secret.md | 2 +- .../src/containers/AskUserContainer.tsx | 4 +++- .../src/containers/ComposerContainer.tsx | 13 +++++++++++ .../src/hooks/useComposerPauseView.ts | 6 +++++ .../test/containers/AskUserContainer.test.tsx | 11 ++++++++++ .../containers/ComposerContainer.test.tsx | 17 ++++++++++++++ .../test/hooks/useComposerPauseView.test.tsx | 22 +++++++++++++++++++ 7 files changed, 73 insertions(+), 2 deletions(-) diff --git a/.changeset/x-tfg-tools-ask-user-secret.md b/.changeset/x-tfg-tools-ask-user-secret.md index d1f2a8c7c9..309d06ba75 100644 --- a/.changeset/x-tfg-tools-ask-user-secret.md +++ b/.changeset/x-tfg-tools-ask-user-secret.md @@ -3,4 +3,4 @@ '@truefoundry/trueforge-ui': patch --- -Accept `x-tfg-tools` on a TrueFoundry-mode turn so the root agent can call `ask_user_secret`. Hide that pending call in live chat so the host secret form owns the prompt. +Accept `x-tfg-tools` as an array of client-side tool definitions on a TrueFoundry-mode turn. A pending `ask_user_secret` call stays out of the plaintext composer unless the host registers a secret form. diff --git a/packages/trueforge-ui/src/containers/AskUserContainer.tsx b/packages/trueforge-ui/src/containers/AskUserContainer.tsx index b0d20b2d82..0d784da9e7 100644 --- a/packages/trueforge-ui/src/containers/AskUserContainer.tsx +++ b/packages/trueforge-ui/src/containers/AskUserContainer.tsx @@ -12,6 +12,7 @@ import { import { useTrackAnalytics } from '../analytics/AnalyticsProvider.js'; import { AnalyticsEvents } from '../analytics/events.js'; import { useSlot } from '../theme/SlotsProvider.js'; +import { ASK_USER_SECRET_TOOL_NAME } from '../utils/toolCallParsing.js'; const EMPTY_ANSWER: AskUserAnswerDraft = { radioValue: '', custom: '' }; @@ -85,7 +86,8 @@ export function AskUserContainer({ disabled = false }: { disabled?: boolean }) { respond({ toolCallId: item.toolCallId, content }); }, [allQuestionsAnswered, answers, disabled, getResponseForQuestion, item, questions.length, respond, track]); - if (item == null || currentQuestion == null) return null; + // The plaintext answer is sent as the tool result. A secret must not take that path. + if (item == null || item.toolName === ASK_USER_SECRET_TOOL_NAME || currentQuestion == null) return null; return ( ; } +function SecretUnavailableNotice() { + return ( +

+ This agent asked for a secret. This chat does not collect it. +

+ ); +} + export function ComposerContainer({ placeholder = 'Ask anything... (Shift+Enter for new line)', }: ComposerContainerProps) { @@ -244,6 +255,8 @@ export function ComposerContainer({ ) : pauseView.kind === 'ask-user' ? ( + ) : pauseView.kind === 'secret-unavailable' ? ( + ) : pauseView.kind === 'approval' ? ( ) : null; diff --git a/packages/trueforge-ui/src/hooks/useComposerPauseView.ts b/packages/trueforge-ui/src/hooks/useComposerPauseView.ts index 2651cd739b..435a0af4ba 100644 --- a/packages/trueforge-ui/src/hooks/useComposerPauseView.ts +++ b/packages/trueforge-ui/src/hooks/useComposerPauseView.ts @@ -4,6 +4,7 @@ import { useAuiState } from '@assistant-ui/react'; import { useTrueForgeApprovals, useTrueForgeToolResponses } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useOptionalCustomActionRenderers } from '../server/CustomActionRenderersContext.js'; +import { ASK_USER_SECRET_TOOL_NAME } from '../utils/toolCallParsing.js'; export type ThreadPauseState = { thread: { @@ -28,6 +29,7 @@ export type ComposerPauseView = | { kind: 'mcp' } | { kind: 'custom'; toolName: string } | { kind: 'ask-user' } + | { kind: 'secret-unavailable' } | { kind: 'approval' } | { kind: 'compose' }; @@ -48,6 +50,10 @@ export function useComposerPauseView(): ComposerPauseView { if (toolName != null && customActionRenderers?.[toolName] != null) { return { kind: 'custom', toolName }; } + // A typed answer would be stored as the tool result. Only a host secret form may answer this. + if (toolName === ASK_USER_SECRET_TOOL_NAME) { + return { kind: 'secret-unavailable' }; + } return { kind: 'ask-user' }; } diff --git a/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx b/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx index 186860f689..3b4dd1de99 100644 --- a/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx @@ -85,6 +85,17 @@ describe('AskUserContainer', () => { useToolResponses.mockReturnValue({ pending: [], respond }); }); + it('does not render a plaintext answer for ask_user_secret', () => { + useToolResponses.mockReturnValue({ + pending: [{ toolCallId: 'secret-1', toolName: 'ask_user_secret', question: 'Paste the token', options: [] }], + respond, + }); + renderSubject(); + + expect(screen.queryByTestId('ask-user-probe')).not.toBeInTheDocument(); + expect(respond).not.toHaveBeenCalled(); + }); + it('renders nothing while there is no pending question', () => { renderSubject(); diff --git a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx index 759b2a5cf9..5082e5c781 100644 --- a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx @@ -317,6 +317,23 @@ describe('ComposerContainer', () => { expect(screen.getByText('Custom right')).toBeInTheDocument(); }); + it('does not collect a typed secret when no host form is registered', () => { + toolResponsesState.pending = [ + { toolCallId: 'secret-1', toolName: 'ask_user_secret', args: { question: 'Paste the token' } }, + ]; + + renderComposer(); + + expect(screen.getByText('This agent asked for a secret. This chat does not collect it.')).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: 'Your answer' })).not.toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Message input' })).toBeEnabled(); + expect(document.querySelector('[data-slot="aui_composer-pause"]')).toHaveAttribute( + 'data-pause-kind', + 'secret-unavailable', + ); + expect(toolResponsesState.respond).not.toHaveBeenCalled(); + }); + it('keeps the composer enabled under a custom action renderer', () => { toolResponsesState.pending = [{ toolCallId: 'tc-1', toolName: 'secret_select', args: { secrets: ['a'] } }]; diff --git a/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx b/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx index f2a918180c..9c7ff3e3f1 100644 --- a/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx +++ b/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx @@ -96,6 +96,28 @@ describe('useComposerPauseView', () => { expect(useAuiState).toHaveBeenCalledWith(threadHasPendingMcpAuth); }); + it('does not offer a plaintext answer for ask_user_secret without a host form', () => { + useToolResponses.mockReturnValue({ + pending: [{ toolCallId: 'secret-1', toolName: 'ask_user_secret', question: 'Paste the token' }], + }); + + const { result } = renderHook(() => useComposerPauseView()); + + expect(result.current).toEqual({ kind: 'secret-unavailable' }); + }); + + it('uses the host form for ask_user_secret when one is registered', () => { + useToolResponses.mockReturnValue({ + pending: [{ toolCallId: 'secret-1', toolName: 'ask_user_secret', args: {} }], + }); + + const { result } = renderHook(() => useComposerPauseView(), { + wrapper: withRenderers({ ask_user_secret: () => null }), + }); + + expect(result.current).toEqual({ kind: 'custom', toolName: 'ask_user_secret' }); + }); + it('shows the ask-user view while tool responses are pending without a custom renderer', () => { useToolResponses.mockReturnValue({ pending: [{ toolCallId: 'question-1', toolName: 'ask_user_question' }] }); From 7d3515d415f504df75f0041253cdcde7dfed9790 Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Wed, 28 Oct 2026 17:06:04 +0530 Subject: [PATCH 4/9] fix: leave ask_user_secret rendering to the host frontend This package no longer special-cases the secret tool. The host that sends x-tfg-tools owns the form. Signed-off-by: Raman Tehlan --- .changeset/x-tfg-tools-ask-user-secret.md | 3 +- .../src/containers/AskUserContainer.tsx | 4 +- .../src/containers/ComposerContainer.tsx | 13 ---- .../src/containers/ToolCallContainer.tsx | 6 -- .../src/hooks/useComposerPauseView.ts | 6 -- .../trueforge-ui/src/utils/toolCallParsing.ts | 1 - .../test/containers/AskUserContainer.test.tsx | 11 ---- .../containers/ComposerContainer.test.tsx | 17 ----- .../containers/ToolCallContainer.test.tsx | 62 ------------------- .../test/hooks/useComposerPauseView.test.tsx | 22 ------- 10 files changed, 2 insertions(+), 143 deletions(-) diff --git a/.changeset/x-tfg-tools-ask-user-secret.md b/.changeset/x-tfg-tools-ask-user-secret.md index 309d06ba75..be2faf9fd4 100644 --- a/.changeset/x-tfg-tools-ask-user-secret.md +++ b/.changeset/x-tfg-tools-ask-user-secret.md @@ -1,6 +1,5 @@ --- '@truefoundry/trueforge': patch -'@truefoundry/trueforge-ui': patch --- -Accept `x-tfg-tools` as an array of client-side tool definitions on a TrueFoundry-mode turn. A pending `ask_user_secret` call stays out of the plaintext composer unless the host registers a secret form. +Accept `x-tfg-tools` as an array of client-side tool definitions on a TrueFoundry-mode turn. diff --git a/packages/trueforge-ui/src/containers/AskUserContainer.tsx b/packages/trueforge-ui/src/containers/AskUserContainer.tsx index 0d784da9e7..b0d20b2d82 100644 --- a/packages/trueforge-ui/src/containers/AskUserContainer.tsx +++ b/packages/trueforge-ui/src/containers/AskUserContainer.tsx @@ -12,7 +12,6 @@ import { import { useTrackAnalytics } from '../analytics/AnalyticsProvider.js'; import { AnalyticsEvents } from '../analytics/events.js'; import { useSlot } from '../theme/SlotsProvider.js'; -import { ASK_USER_SECRET_TOOL_NAME } from '../utils/toolCallParsing.js'; const EMPTY_ANSWER: AskUserAnswerDraft = { radioValue: '', custom: '' }; @@ -86,8 +85,7 @@ export function AskUserContainer({ disabled = false }: { disabled?: boolean }) { respond({ toolCallId: item.toolCallId, content }); }, [allQuestionsAnswered, answers, disabled, getResponseForQuestion, item, questions.length, respond, track]); - // The plaintext answer is sent as the tool result. A secret must not take that path. - if (item == null || item.toolName === ASK_USER_SECRET_TOOL_NAME || currentQuestion == null) return null; + if (item == null || currentQuestion == null) return null; return ( ; } -function SecretUnavailableNotice() { - return ( -

- This agent asked for a secret. This chat does not collect it. -

- ); -} - export function ComposerContainer({ placeholder = 'Ask anything... (Shift+Enter for new line)', }: ComposerContainerProps) { @@ -255,8 +244,6 @@ export function ComposerContainer({ ) : pauseView.kind === 'ask-user' ? ( - ) : pauseView.kind === 'secret-unavailable' ? ( - ) : pauseView.kind === 'approval' ? ( ) : null; diff --git a/packages/trueforge-ui/src/containers/ToolCallContainer.tsx b/packages/trueforge-ui/src/containers/ToolCallContainer.tsx index 15622a174a..b5777d6948 100644 --- a/packages/trueforge-ui/src/containers/ToolCallContainer.tsx +++ b/packages/trueforge-ui/src/containers/ToolCallContainer.tsx @@ -19,7 +19,6 @@ import { AnalyticsEvents } from '../analytics/events.js'; import { useSlot } from '../theme/SlotsProvider.js'; import { resolveActivityLabel } from '../utils/resolveActivityLabel.js'; import { - ASK_USER_SECRET_TOOL_NAME, ASK_USER_TOOL_NAME, MCP_META_TOOLS, SANDBOX_TOOL_NAMES, @@ -183,11 +182,6 @@ export const ToolCallContainer: ToolCallMessagePartComponent = part => { const expandSubAgent = useCallback(() => setExpanded(true), []); useRegisterApprovalExpand(isSubAgent ? part.toolCallId : '', expandSubAgent); - // Live chat: the host secret form owns this pause. A thread card would repeat the field list. - if (part.toolName === ASK_USER_SECRET_TOOL_NAME && !isSessionReplay && getAskUserAnswerResult(part.result) == null) { - return null; - } - if (part.toolName === ASK_USER_TOOL_NAME) { const { question, options = [] } = parseAskUserQuestionArgs(part.argsText); const answer = getAskUserAnswerResult(part.result); diff --git a/packages/trueforge-ui/src/hooks/useComposerPauseView.ts b/packages/trueforge-ui/src/hooks/useComposerPauseView.ts index 435a0af4ba..2651cd739b 100644 --- a/packages/trueforge-ui/src/hooks/useComposerPauseView.ts +++ b/packages/trueforge-ui/src/hooks/useComposerPauseView.ts @@ -4,7 +4,6 @@ import { useAuiState } from '@assistant-ui/react'; import { useTrueForgeApprovals, useTrueForgeToolResponses } from '@truefoundry/trueforge-assistant-ui-runtime'; import { useOptionalCustomActionRenderers } from '../server/CustomActionRenderersContext.js'; -import { ASK_USER_SECRET_TOOL_NAME } from '../utils/toolCallParsing.js'; export type ThreadPauseState = { thread: { @@ -29,7 +28,6 @@ export type ComposerPauseView = | { kind: 'mcp' } | { kind: 'custom'; toolName: string } | { kind: 'ask-user' } - | { kind: 'secret-unavailable' } | { kind: 'approval' } | { kind: 'compose' }; @@ -50,10 +48,6 @@ export function useComposerPauseView(): ComposerPauseView { if (toolName != null && customActionRenderers?.[toolName] != null) { return { kind: 'custom', toolName }; } - // A typed answer would be stored as the tool result. Only a host secret form may answer this. - if (toolName === ASK_USER_SECRET_TOOL_NAME) { - return { kind: 'secret-unavailable' }; - } return { kind: 'ask-user' }; } diff --git a/packages/trueforge-ui/src/utils/toolCallParsing.ts b/packages/trueforge-ui/src/utils/toolCallParsing.ts index 4572673f31..6a7877aefb 100644 --- a/packages/trueforge-ui/src/utils/toolCallParsing.ts +++ b/packages/trueforge-ui/src/utils/toolCallParsing.ts @@ -13,7 +13,6 @@ export type ApprovalOptionView = { export const SUB_AGENT_TOOL_NAME = 'create_sub_agent'; export const ASK_USER_TOOL_NAME = 'ask_user_question'; -export const ASK_USER_SECRET_TOOL_NAME = 'ask_user_secret'; /** Tool names that should be rendered as a sandbox execution card. */ export const SANDBOX_TOOL_NAMES = new Set(['exec', 'sandbox_exec']); diff --git a/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx b/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx index 3b4dd1de99..186860f689 100644 --- a/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/AskUserContainer.test.tsx @@ -85,17 +85,6 @@ describe('AskUserContainer', () => { useToolResponses.mockReturnValue({ pending: [], respond }); }); - it('does not render a plaintext answer for ask_user_secret', () => { - useToolResponses.mockReturnValue({ - pending: [{ toolCallId: 'secret-1', toolName: 'ask_user_secret', question: 'Paste the token', options: [] }], - respond, - }); - renderSubject(); - - expect(screen.queryByTestId('ask-user-probe')).not.toBeInTheDocument(); - expect(respond).not.toHaveBeenCalled(); - }); - it('renders nothing while there is no pending question', () => { renderSubject(); diff --git a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx index 5082e5c781..759b2a5cf9 100644 --- a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx @@ -317,23 +317,6 @@ describe('ComposerContainer', () => { expect(screen.getByText('Custom right')).toBeInTheDocument(); }); - it('does not collect a typed secret when no host form is registered', () => { - toolResponsesState.pending = [ - { toolCallId: 'secret-1', toolName: 'ask_user_secret', args: { question: 'Paste the token' } }, - ]; - - renderComposer(); - - expect(screen.getByText('This agent asked for a secret. This chat does not collect it.')).toBeInTheDocument(); - expect(screen.queryByRole('textbox', { name: 'Your answer' })).not.toBeInTheDocument(); - expect(screen.getByRole('textbox', { name: 'Message input' })).toBeEnabled(); - expect(document.querySelector('[data-slot="aui_composer-pause"]')).toHaveAttribute( - 'data-pause-kind', - 'secret-unavailable', - ); - expect(toolResponsesState.respond).not.toHaveBeenCalled(); - }); - it('keeps the composer enabled under a custom action renderer', () => { toolResponsesState.pending = [{ toolCallId: 'tc-1', toolName: 'secret_select', args: { secrets: ['a'] } }]; diff --git a/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx b/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx index b2744a03f4..70e380283b 100644 --- a/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx @@ -193,68 +193,6 @@ describe('ToolCallContainer', () => { ); }); - it('hides pending ask_user_secret in live chat so the host form owns the prompt', () => { - renderToolCallMessage([ - { - type: 'tool-call', - toolCallId: 'secret-pending', - toolName: 'ask_user_secret', - args: {}, - argsText: JSON.stringify({ - question: 'Provide the API key', - fields: [{ key: 'api_key', label: 'API key', required: true }], - }), - interrupt: { type: 'human', payload: {} }, - }, - ]); - - expect(screen.queryByText('ask_user_secret')).not.toBeInTheDocument(); - }); - - it('shows a pending ask_user_secret card in session replay', () => { - const message: ThreadMessageLike = { - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'secret-pending', - toolName: 'ask_user_secret', - args: {}, - argsText: JSON.stringify({ - question: 'Provide the API key', - fields: [{ key: 'api_key', label: 'API key', required: true }], - }), - interrupt: { type: 'human', payload: {} }, - }, - ], - }; - - render( - - - {() => } - - , - ); - - expect(screen.getByText('ask_user_secret')).toBeInTheDocument(); - }); - - it('shows ask_user_secret after the placeholder is stored', () => { - renderToolCallMessage([ - { - type: 'tool-call', - toolCallId: 'secret-done', - toolName: 'ask_user_secret', - args: {}, - argsText: '{}', - result: '{"data":[{"key":"api_key","placeholder":"tfy-ephemeral-secret://abc"}]}', - }, - ]); - - expect(screen.getByText('ask_user_secret')).toBeInTheDocument(); - }); - it('hides pending ask_user in live chat so the composer owns the prompt', () => { const AskUserPrompt = createAskUserPromptProbe(); diff --git a/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx b/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx index 9c7ff3e3f1..f2a918180c 100644 --- a/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx +++ b/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx @@ -96,28 +96,6 @@ describe('useComposerPauseView', () => { expect(useAuiState).toHaveBeenCalledWith(threadHasPendingMcpAuth); }); - it('does not offer a plaintext answer for ask_user_secret without a host form', () => { - useToolResponses.mockReturnValue({ - pending: [{ toolCallId: 'secret-1', toolName: 'ask_user_secret', question: 'Paste the token' }], - }); - - const { result } = renderHook(() => useComposerPauseView()); - - expect(result.current).toEqual({ kind: 'secret-unavailable' }); - }); - - it('uses the host form for ask_user_secret when one is registered', () => { - useToolResponses.mockReturnValue({ - pending: [{ toolCallId: 'secret-1', toolName: 'ask_user_secret', args: {} }], - }); - - const { result } = renderHook(() => useComposerPauseView(), { - wrapper: withRenderers({ ask_user_secret: () => null }), - }); - - expect(result.current).toEqual({ kind: 'custom', toolName: 'ask_user_secret' }); - }); - it('shows the ask-user view while tool responses are pending without a custom renderer', () => { useToolResponses.mockReturnValue({ pending: [{ toolCallId: 'question-1', toolName: 'ask_user_question' }] }); From 44389ae1da1f1ec8a49f0865a48618859d2395ee Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Thu, 29 Oct 2026 16:14:47 +0530 Subject: [PATCH 5/9] review comments --- packages/trueforge/src/apis/turns.ts | 2 +- .../TrueFoundryTurnResourceResolver.ts | 2 +- .../{tfgTools.ts => inlineTools.ts} | 29 ++----------------- .../TrueFoundryTurnResourceResolver.test.ts | 4 +-- .../tests/unit/truefoundry/tfgTools.test.ts | 16 +++++----- 5 files changed, 15 insertions(+), 38 deletions(-) rename packages/trueforge/src/truefoundry/{tfgTools.ts => inlineTools.ts} (88%) diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 6895abc71b..21e804c1af 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -75,7 +75,7 @@ import { parseTfgTools, X_TFG_TOOLS, type TfgToolDefinition, -} from '../truefoundry/tfgTools'; +} from '../truefoundry/inlineTools'; import { TrueFoundryTurnResourceResolver } from '../truefoundry/TrueFoundryTurnResourceResolver'; import { newId } from '../utils/id'; import { resolveWebSearchProvider } from '../websearch/providers'; diff --git a/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts b/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts index 84a6f8bf76..4b5f98f392 100644 --- a/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts +++ b/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts @@ -1,5 +1,5 @@ import { TurnResourceResolver } from '@truefoundry/trueforge-core/agent-session'; -import { tfgToolsCapability, type TfgToolDefinition } from './tfgTools'; +import { tfgToolsCapability, type TfgToolDefinition } from './inlineTools'; type TurnResolverDeps = ConstructorParameters[0]; diff --git a/packages/trueforge/src/truefoundry/tfgTools.ts b/packages/trueforge/src/truefoundry/inlineTools.ts similarity index 88% rename from packages/trueforge/src/truefoundry/tfgTools.ts rename to packages/trueforge/src/truefoundry/inlineTools.ts index f7e194d6ed..333de3b384 100644 --- a/packages/trueforge/src/truefoundry/tfgTools.ts +++ b/packages/trueforge/src/truefoundry/inlineTools.ts @@ -9,31 +9,11 @@ import { HTTPException } from 'hono/http-exception'; /** Per-turn tool definitions. Not stored, so a resume turn must send the header again. */ export const X_TFG_TOOLS = 'x-tfg-tools'; -const TFG_TOOLS_SERVER_ID = 'tfg-tools'; const MAX_TOOLS = 16; const MAX_DESCRIPTION_LENGTH = 4_000; const MAX_HEADER_LENGTH = 16_000; const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; -/** - * Harness system tools register first and keep their names. A header tool with one of these - * names would be renamed, so the caller and the UI would no longer find it. - */ -const RESERVED_TOOL_NAMES = new Set([ - 'ask_user_question', - 'call_tool', - 'create_sub_agent', - 'exec', - 'get_current_datetime', - 'get_openui_instructions', - 'get_tool_info', - 'get_tool_output_schema', - 'list_tools', - 'unknown', - 'web_fetch', - 'web_search', -]); - export interface TfgToolParameters { type: 'object'; properties?: Record; @@ -90,12 +70,12 @@ export function parseTfgTools(raw: string): readonly TfgToolDefinition[] { export function tfgToolsCapability(tools: readonly TfgToolDefinition[]): AgentCapability { return { - systemToolSets: [new HeaderClientSideTools(tools)], + systemToolSets: [new InlineClientSideTools(tools)], }; } -class HeaderClientSideTools extends ClientSideTool { - readonly name = TFG_TOOLS_SERVER_ID; +class InlineClientSideTools extends ClientSideTool { + readonly name = X_TFG_TOOLS; readonly displayName = 'TfgTools'; override readonly tracingEnabled = false; readonly #tools: ToolDefinition[]; @@ -131,9 +111,6 @@ function parseToolDefinition(value: unknown, index: number): TfgToolDefinition { message: entryMessage(index, 'name must be 1-64 characters of letters, numbers, "_" or "-"'), }); } - if (RESERVED_TOOL_NAMES.has(name)) { - throw new HTTPException(400, { message: entryMessage(index, `name "${name}" is reserved`) }); - } const description = value['description']; if (typeof description !== 'string') { throw new HTTPException(400, { message: entryMessage(index, 'description must be a non-empty string') }); diff --git a/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts b/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts index bd563b7ad1..4f6816b7be 100644 --- a/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts @@ -2,7 +2,7 @@ import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; import type { AgentInfo, ILLM } from '@truefoundry/trueforge-core/core'; import { createLogger } from 'winston'; import { TrueFoundryTurnResourceResolver } from '../../../src/truefoundry/TrueFoundryTurnResourceResolver'; -import { parseTfgTools, type TfgToolDefinition } from '../../../src/truefoundry/tfgTools'; +import { parseTfgTools, type TfgToolDefinition } from '../../../src/truefoundry/inlineTools'; const unusedLlm: ILLM = { create: () => { @@ -53,7 +53,7 @@ describe('TrueFoundryTurnResourceResolver', () => { expect( root.extraCapabilities?.flatMap(capability => capability.systemToolSets ?? []).map(toolSet => toolSet.name), - ).toEqual(['tfg-tools']); + ).toEqual(['x-tfg-tools']); expect(nested.extraCapabilities).toBeUndefined(); }); diff --git a/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts index 9251aa4d7e..d8a2d38828 100644 --- a/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts @@ -4,7 +4,7 @@ import { parseTfgTools, tfgToolsCapability, X_TFG_TOOLS, -} from '../../../src/truefoundry/tfgTools'; +} from '../../../src/truefoundry/inlineTools'; const askUserSecret = { name: 'ask_user_secret', @@ -37,6 +37,11 @@ describe('parseTfgTools', () => { expect(parseTfgTools(JSON.stringify([askUserSecret]))).toEqual([askUserSecret]); }); + it('accepts a name that matches a harness tool', () => { + const tool = { ...askUserSecret, name: 'ask_user_question' }; + expect(parseTfgTools(JSON.stringify([tool]))).toEqual([tool]); + }); + it('accepts an empty list', () => { expect(parseTfgTools('[]')).toEqual([]); }); @@ -45,11 +50,6 @@ describe('parseTfgTools', () => { ['not json', 'not-json', `${X_TFG_TOOLS} must be a JSON array`], ['an object', '{}', `${X_TFG_TOOLS} must be a JSON array of tool definitions`], ['a string entry', '["ask_user_secret"]', `${X_TFG_TOOLS}[0] must be an object`], - [ - 'a reserved name', - JSON.stringify([{ ...askUserSecret, name: 'ask_user_question' }]), - `${X_TFG_TOOLS}[0] name "ask_user_question" is reserved`, - ], [ 'a bad name', JSON.stringify([{ ...askUserSecret, name: 'has space' }]), @@ -119,8 +119,8 @@ describe('tfgToolsCapability', () => { expect(response.clientSideToolRequired.tool_info).toEqual({ type: 'truefoundry-system', original_tool_name: 'ask_user_secret', - mcp_server_id: 'tfg-tools', - mcp_server_name: 'tfg-tools', + mcp_server_id: 'x-tfg-tools', + mcp_server_name: 'x-tfg-tools', is_client_side: true, }); }); From 62a89ae905b1c2f13398393ef3a0111190f01fd0 Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Thu, 29 Oct 2026 16:27:51 +0530 Subject: [PATCH 6/9] review comments: replace definiation with schema --- .changeset/x-tfg-tools-ask-user-secret.md | 2 +- packages/trueforge/src/apis/turns.ts | 11 +- .../TrueFoundryTurnResourceResolver.ts | 6 +- .../trueforge/src/truefoundry/inlineTools.ts | 125 +++++------------- .../TrueFoundryTurnResourceResolver.test.ts | 6 +- .../tests/unit/truefoundry/tfgTools.test.ts | 42 +++--- 6 files changed, 68 insertions(+), 124 deletions(-) diff --git a/.changeset/x-tfg-tools-ask-user-secret.md b/.changeset/x-tfg-tools-ask-user-secret.md index be2faf9fd4..6460a90c5a 100644 --- a/.changeset/x-tfg-tools-ask-user-secret.md +++ b/.changeset/x-tfg-tools-ask-user-secret.md @@ -2,4 +2,4 @@ '@truefoundry/trueforge': patch --- -Accept `x-tfg-tools` as an array of client-side tool definitions on a TrueFoundry-mode turn. +Accept `x-tfg-tools` as an array of MCP tool definitions (`inputSchema`, optional `outputSchema`) on a TrueFoundry-mode turn. diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 21e804c1af..acc0122e76 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -70,12 +70,7 @@ import { import { DEFAULT_SANDBOX_ENVIRONMENT_NAME } from '../schemas/sandboxEnvironment'; import { MAX_SESSION_TITLE_LENGTH } from '../schemas/session'; import { assertGatewayMetadataRequestHeaders } from '../truefoundry/gatewayMetadata'; -import { - assertTfgToolsHeaderAllowed, - parseTfgTools, - X_TFG_TOOLS, - type TfgToolDefinition, -} from '../truefoundry/inlineTools'; +import { assertTfgToolsHeaderAllowed, parseTfgTools, X_TFG_TOOLS, type InlineTool } from '../truefoundry/inlineTools'; import { TrueFoundryTurnResourceResolver } from '../truefoundry/TrueFoundryTurnResourceResolver'; import { newId } from '../utils/id'; import { resolveWebSearchProvider } from '../websearch/providers'; @@ -166,7 +161,7 @@ interface BeginTurnExecutionParams { /** Raw inbound request headers. Absent for a schedule run. */ requestHeaders?: Record; /** Tool definitions from `x-tfg-tools`. Absent on schedule runs, which have no request headers. */ - tfgTools?: readonly TfgToolDefinition[]; + tfgTools?: readonly InlineTool[]; deps: BeginTurnExecutionDeps; } @@ -187,7 +182,7 @@ function createTurnResolver(deps: { userRef: string; session: SessionHandle; turnMetadata: TurnMetadata; - tfgTools: readonly TfgToolDefinition[]; + tfgTools: readonly InlineTool[]; }): TurnResourceResolver { const { mcpServerStore, diff --git a/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts b/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts index 4b5f98f392..94dcc6bc6b 100644 --- a/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts +++ b/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts @@ -1,13 +1,13 @@ import { TurnResourceResolver } from '@truefoundry/trueforge-core/agent-session'; -import { tfgToolsCapability, type TfgToolDefinition } from './inlineTools'; +import { tfgToolsCapability, type InlineTool } from './inlineTools'; type TurnResolverDeps = ConstructorParameters[0]; /** Root-only mount for `x-tfg-tools`. A child thread passes `agent_info` and does not get the tools. */ export class TrueFoundryTurnResourceResolver extends TurnResourceResolver { - readonly #tools: readonly TfgToolDefinition[]; + readonly #tools: readonly InlineTool[]; - constructor(input: { deps: TurnResolverDeps; tools: readonly TfgToolDefinition[] }) { + constructor(input: { deps: TurnResolverDeps; tools: readonly InlineTool[] }) { super(input.deps); this.#tools = input.tools; } diff --git a/packages/trueforge/src/truefoundry/inlineTools.ts b/packages/trueforge/src/truefoundry/inlineTools.ts index 333de3b384..ad27692706 100644 --- a/packages/trueforge/src/truefoundry/inlineTools.ts +++ b/packages/trueforge/src/truefoundry/inlineTools.ts @@ -1,3 +1,4 @@ +import { ToolSchema as McpToolSchema } from '@modelcontextprotocol/sdk/types.js'; import { ClientSideTool, toolResultResponse, @@ -5,27 +6,19 @@ import { type ToolDefinition, } from '@truefoundry/trueforge-core/core'; import { HTTPException } from 'hono/http-exception'; +import { z } from 'zod'; /** Per-turn tool definitions. Not stored, so a resume turn must send the header again. */ export const X_TFG_TOOLS = 'x-tfg-tools'; const MAX_TOOLS = 16; -const MAX_DESCRIPTION_LENGTH = 4_000; const MAX_HEADER_LENGTH = 16_000; -const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; -export interface TfgToolParameters { - type: 'object'; - properties?: Record; - required?: string[]; - [key: string]: unknown; -} +const InlineToolSchema = McpToolSchema; +const InlineToolsHeaderSchema = z.array(InlineToolSchema); -export interface TfgToolDefinition { - name: string; - description: string; - parameters: TfgToolParameters; -} +/** One MCP tool definition from `x-tfg-tools`. `execute` is attached later and is not part of the header. */ +export type InlineTool = z.infer; /** Reject the header outside TrueFoundry mode before parsing, so a bad payload does not hide the mode error. */ export function assertTfgToolsHeaderAllowed(input: { isTrueFoundryMode: boolean }): void { @@ -38,7 +31,7 @@ export function assertTfgToolsHeaderAllowed(input: { isTrueFoundryMode: boolean * Tool definitions from `x-tfg-tools` for this turn. Not stored, so a resume must send the header again. * Rejects a bad value rather than mounting a partial list. */ -export function parseTfgTools(raw: string): readonly TfgToolDefinition[] { +export function parseTfgTools(raw: string): readonly InlineTool[] { if (raw.length > MAX_HEADER_LENGTH) { throw new HTTPException(400, { message: `${X_TFG_TOOLS} is too large` }); } @@ -55,20 +48,25 @@ export function parseTfgTools(raw: string): readonly TfgToolDefinition[] { throw new HTTPException(400, { message: `${X_TFG_TOOLS} lists more than ${String(MAX_TOOLS)} tools` }); } - const tools: TfgToolDefinition[] = []; + const parsed = InlineToolsHeaderSchema.safeParse(decoded); + if (!parsed.success) { + throw new HTTPException(400, { + message: schemaErrorMessage(parsed.error), + cause: parsed.error, + }); + } + const seen = new Set(); - for (let index = 0; index < decoded.length; index++) { - const tool = parseToolDefinition(decoded[index], index); + for (const [index, tool] of parsed.data.entries()) { if (seen.has(tool.name)) { throw new HTTPException(400, { message: entryMessage(index, `name "${tool.name}" is listed more than once`) }); } seen.add(tool.name); - tools.push(tool); } - return tools; + return parsed.data; } -export function tfgToolsCapability(tools: readonly TfgToolDefinition[]): AgentCapability { +export function tfgToolsCapability(tools: readonly InlineTool[]): AgentCapability { return { systemToolSets: [new InlineClientSideTools(tools)], }; @@ -80,12 +78,10 @@ class InlineClientSideTools extends ClientSideTool { override readonly tracingEnabled = false; readonly #tools: ToolDefinition[]; - constructor(tools: readonly TfgToolDefinition[]) { + constructor(tools: readonly InlineTool[]) { super(); this.#tools = tools.map(tool => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.parameters, + ...tool, execute: () => Promise.resolve( toolResultResponse({ @@ -101,81 +97,26 @@ class InlineClientSideTools extends ClientSideTool { } } -function parseToolDefinition(value: unknown, index: number): TfgToolDefinition { - if (!isPlainObject(value)) { - throw new HTTPException(400, { message: entryMessage(index, 'must be an object') }); - } - const name = value['name']; - if (typeof name !== 'string' || !TOOL_NAME_PATTERN.test(name)) { - throw new HTTPException(400, { - message: entryMessage(index, 'name must be 1-64 characters of letters, numbers, "_" or "-"'), - }); - } - const description = value['description']; - if (typeof description !== 'string') { - throw new HTTPException(400, { message: entryMessage(index, 'description must be a non-empty string') }); - } - const trimmedDescription = description.trim(); - if (trimmedDescription.length === 0) { - throw new HTTPException(400, { message: entryMessage(index, 'description must be a non-empty string') }); - } - if (trimmedDescription.length > MAX_DESCRIPTION_LENGTH) { - throw new HTTPException(400, { - message: entryMessage(index, `description must be at most ${String(MAX_DESCRIPTION_LENGTH)} characters`), - }); +function schemaErrorMessage(error: z.ZodError): string { + const issue = error.issues[0]; + if (issue === undefined) { + return `${X_TFG_TOOLS} must be an array of MCP tool definitions`; } - return { - name, - description: trimmedDescription, - parameters: parseParameters(value['parameters'], index), - }; + return `${X_TFG_TOOLS}${issuePath(issue.path)} ${issue.message}`; } -function parseParameters(value: unknown, index: number): TfgToolParameters { - if (!isPlainObject(value) || value['type'] !== 'object') { - throw new HTTPException(400, { message: entryMessage(index, 'parameters must be a JSON object schema') }); - } - const parameters: TfgToolParameters = { type: 'object' }; - const properties = value['properties']; - if (properties !== undefined) { - if (!isPropertyMap(properties)) { - throw new HTTPException(400, { message: entryMessage(index, 'parameters.properties must be an object') }); - } - parameters.properties = properties; - } - const required = value['required']; - if (required !== undefined) { - if (!isStringArray(required)) { - throw new HTTPException(400, { - message: entryMessage(index, 'parameters.required must be an array of strings'), - }); - } - parameters.required = required; - } - for (const [key, entry] of Object.entries(value)) { - if (key === 'type' || key === 'properties' || key === 'required') { - continue; +function issuePath(path: readonly PropertyKey[]): string { + let rendered = ''; + for (const part of path) { + if (typeof part === 'number') { + rendered += `[${String(part)}]`; + } else { + rendered += `.${String(part)}`; } - parameters[key] = entry; } - return parameters; + return rendered; } function entryMessage(index: number, detail: string): string { return `${X_TFG_TOOLS}[${String(index)}] ${detail}`; } - -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isPropertyMap(value: unknown): value is Record { - if (!isPlainObject(value)) { - return false; - } - return Object.values(value).every(entry => isPlainObject(entry)); -} - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every(entry => typeof entry === 'string'); -} diff --git a/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts b/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts index 4f6816b7be..5e51e1a18d 100644 --- a/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts @@ -2,7 +2,7 @@ import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; import type { AgentInfo, ILLM } from '@truefoundry/trueforge-core/core'; import { createLogger } from 'winston'; import { TrueFoundryTurnResourceResolver } from '../../../src/truefoundry/TrueFoundryTurnResourceResolver'; -import { parseTfgTools, type TfgToolDefinition } from '../../../src/truefoundry/inlineTools'; +import { parseTfgTools, type InlineTool } from '../../../src/truefoundry/inlineTools'; const unusedLlm: ILLM = { create: () => { @@ -16,12 +16,12 @@ const askUserSecret = parseTfgTools( { name: 'ask_user_secret', description: 'Collect a secret.', - parameters: { type: 'object', properties: { question: { type: 'string' } }, required: ['question'] }, + inputSchema: { type: 'object', properties: { question: { type: 'string' } }, required: ['question'] }, }, ]), ); -function resolverWith(tools: readonly TfgToolDefinition[]): TrueFoundryTurnResourceResolver { +function resolverWith(tools: readonly InlineTool[]): TrueFoundryTurnResourceResolver { return new TrueFoundryTurnResourceResolver({ tools, deps: { diff --git a/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts index d8a2d38828..7e886abfdf 100644 --- a/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts @@ -1,4 +1,5 @@ import { HTTPException } from 'hono/http-exception'; +import { ZodError } from 'zod'; import { assertTfgToolsHeaderAllowed, parseTfgTools, @@ -9,7 +10,7 @@ import { const askUserSecret = { name: 'ask_user_secret', description: 'Collect a secret and return a tfy-ephemeral-secret:// placeholder for apply_manifest.', - parameters: { + inputSchema: { type: 'object', properties: { question: { type: 'string' }, @@ -18,6 +19,12 @@ const askUserSecret = { required: ['question', 'fields'], additionalProperties: false, }, + outputSchema: { + type: 'object', + properties: { + secret: { type: 'string' }, + }, + }, }; describe('assertTfgToolsHeaderAllowed', () => { @@ -33,10 +40,15 @@ describe('assertTfgToolsHeaderAllowed', () => { }); describe('parseTfgTools', () => { - it('parses a tool definition and keeps extra parameter keys', () => { + it('parses a tool definition and keeps outputSchema and extra inputSchema keys', () => { expect(parseTfgTools(JSON.stringify([askUserSecret]))).toEqual([askUserSecret]); }); + it('accepts a tool with only a name and inputSchema', () => { + const tool = { name: 'ask_user_secret', inputSchema: { type: 'object' } }; + expect(parseTfgTools(JSON.stringify([tool]))).toEqual([tool]); + }); + it('accepts a name that matches a harness tool', () => { const tool = { ...askUserSecret, name: 'ask_user_question' }; expect(parseTfgTools(JSON.stringify([tool]))).toEqual([tool]); @@ -49,21 +61,11 @@ describe('parseTfgTools', () => { it.each([ ['not json', 'not-json', `${X_TFG_TOOLS} must be a JSON array`], ['an object', '{}', `${X_TFG_TOOLS} must be a JSON array of tool definitions`], - ['a string entry', '["ask_user_secret"]', `${X_TFG_TOOLS}[0] must be an object`], + ['a string entry', '["ask_user_secret"]', `${X_TFG_TOOLS}[0]`], [ - 'a bad name', - JSON.stringify([{ ...askUserSecret, name: 'has space' }]), - `${X_TFG_TOOLS}[0] name must be 1-64 characters of letters, numbers, "_" or "-"`, - ], - [ - 'an empty description', - JSON.stringify([{ ...askUserSecret, description: ' ' }]), - `${X_TFG_TOOLS}[0] description must be a non-empty string`, - ], - [ - 'parameters that are not an object schema', - JSON.stringify([{ ...askUserSecret, parameters: { type: 'string' } }]), - `${X_TFG_TOOLS}[0] parameters must be a JSON object schema`, + 'parameters instead of inputSchema', + JSON.stringify([{ name: 'ask_user_secret', description: 'Collect a secret.', parameters: { type: 'object' } }]), + `${X_TFG_TOOLS}[0].inputSchema`, ], [ 'a duplicate name', @@ -76,7 +78,7 @@ describe('parseTfgTools', () => { `${X_TFG_TOOLS} lists more than 16 tools`, ], ])('rejects %s', (_case, raw, message) => { - expect(() => parseTfgTools(raw)).toThrow(expect.objectContaining({ message })); + expect(() => parseTfgTools(raw)).toThrow(expect.objectContaining({ message: expect.stringContaining(message) })); }); it('rejects a header that is too large', () => { @@ -89,6 +91,12 @@ describe('parseTfgTools', () => { expect(() => parseTfgTools('not-json')).toThrow(expect.objectContaining({ cause: expect.any(SyntaxError) })); }); + it('keeps a schema failure as the cause', () => { + expect(() => parseTfgTools('["ask_user_secret"]')).toThrow( + expect.objectContaining({ cause: expect.any(ZodError) }), + ); + }); + it('rejects with HTTPException', () => { expect(() => parseTfgTools('{}')).toThrow(HTTPException); }); From b840e8d97e2b161752f6d7783451b60c396b1f94 Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Thu, 29 Oct 2026 18:17:40 +0530 Subject: [PATCH 7/9] Move assert tfgToolsHeader to turns.ts --- .changeset/x-tfg-tools-ask-user-secret.md | 3 +- packages/trueforge/src/apis/turns.ts | 76 +++++++++++++++++- .../trueforge/src/truefoundry/inlineTools.ts | 79 +------------------ .../TrueFoundryTurnResourceResolver.test.ts | 3 +- .../tests/unit/truefoundry/tfgTools.test.ts | 11 ++- 5 files changed, 85 insertions(+), 87 deletions(-) diff --git a/.changeset/x-tfg-tools-ask-user-secret.md b/.changeset/x-tfg-tools-ask-user-secret.md index 6460a90c5a..12121c6e5c 100644 --- a/.changeset/x-tfg-tools-ask-user-secret.md +++ b/.changeset/x-tfg-tools-ask-user-secret.md @@ -1,5 +1,6 @@ --- '@truefoundry/trueforge': patch +'@truefoundry/trueforge-core': patch --- -Accept `x-tfg-tools` as an array of MCP tool definitions (`inputSchema`, optional `outputSchema`) on a TrueFoundry-mode turn. +Accept `x-tfg-tools` as an array of MCP tool definitions (`inputSchema`, optional `outputSchema`) on a TrueFoundry-mode turn, and keep `outputSchema` and `annotations` when those local tools are listed. diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 52a0fe90a5..9f0c10adb3 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -2,6 +2,7 @@ * DB-backed turns API (mounted at /api/v1/sessions). */ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; +import { ToolSchema as McpToolSchema } from '@modelcontextprotocol/sdk/types.js'; import type { ISessionStore, Sessions, Turn, TurnStreamingEvent } from '@truefoundry/trueforge-core/agent-session'; import { CancellationReason, @@ -35,6 +36,7 @@ import type { Context } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { streamSSE } from 'hono/streaming'; import type { Logger } from 'winston'; +import { z } from 'zod'; import type { ResolveMcpServerStoreInput, ResolveModelProviderStoreInput, ResolveTurnSkillsStoreInput } from '../app'; import type { Authorizer } from '../auth/authorizer'; import type { ResolveRequestContext } from '../auth/identity'; @@ -71,7 +73,7 @@ import { import { DEFAULT_SANDBOX_ENVIRONMENT_NAME } from '../schemas/sandboxEnvironment'; import { MAX_SESSION_TITLE_LENGTH } from '../schemas/session'; import { assertGatewayMetadataRequestHeaders } from '../truefoundry/gatewayMetadata'; -import { assertTfgToolsHeaderAllowed, parseTfgTools, X_TFG_TOOLS, type InlineTool } from '../truefoundry/inlineTools'; +import { X_TFG_TOOLS, type InlineTool } from '../truefoundry/inlineTools'; import { TrueFoundryTurnResourceResolver } from '../truefoundry/TrueFoundryTurnResourceResolver'; import { newId } from '../utils/id'; import { resolveWebSearchProvider } from '../websearch/providers'; @@ -637,6 +639,78 @@ function isSessionOwner({ const FORBIDDEN_SESSION_ACCESS = 'Only the session creator can access this session'; const FORBIDDEN_CREATE_TURN = 'Only the session creator can create turns'; +const MAX_TFG_TOOLS = 16; +const MAX_TFG_TOOLS_HEADER_LENGTH = 16_000; +const InlineToolsHeaderSchema = z.array(McpToolSchema); + +/** Reject the header outside TrueFoundry mode before parsing, so a bad payload does not hide the mode error. */ +export function assertTfgToolsHeaderAllowed(input: { isTrueFoundryMode: boolean }): void { + if (!input.isTrueFoundryMode) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} requires TrueFoundry mode` }); + } +} + +/** + * Tool definitions from `x-tfg-tools` for this turn. Not stored, so a resume must send the header again. + * Rejects a bad value rather than mounting a partial list. + */ +export function parseTfgTools(raw: string): readonly InlineTool[] { + if (raw.length > MAX_TFG_TOOLS_HEADER_LENGTH) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} is too large` }); + } + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch (error) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array`, cause: error }); + } + if (!Array.isArray(decoded)) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array of tool definitions` }); + } + if (decoded.length > MAX_TFG_TOOLS) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} lists more than ${String(MAX_TFG_TOOLS)} tools` }); + } + + const parsed = InlineToolsHeaderSchema.safeParse(decoded); + if (!parsed.success) { + throw new HTTPException(400, { + message: tfgToolsSchemaErrorMessage(parsed.error), + cause: parsed.error, + }); + } + + const seen = new Set(); + for (const [index, tool] of parsed.data.entries()) { + if (seen.has(tool.name)) { + throw new HTTPException(400, { + message: `${X_TFG_TOOLS}[${String(index)}] name "${tool.name}" is listed more than once`, + }); + } + seen.add(tool.name); + } + return parsed.data; +} + +function tfgToolsSchemaErrorMessage(error: z.ZodError): string { + const issue = error.issues[0]; + if (issue === undefined) { + return `${X_TFG_TOOLS} must be an array of MCP tool definitions`; + } + return `${X_TFG_TOOLS}${tfgToolsIssuePath(issue.path)} ${issue.message}`; +} + +function tfgToolsIssuePath(path: readonly PropertyKey[]): string { + let rendered = ''; + for (const part of path) { + if (typeof part === 'number') { + rendered += `[${String(part)}]`; + } else { + rendered += `.${String(part)}`; + } + } + return rendered; +} + /** DB-backed turns (mounted at /api/v1/sessions). */ export function createTurnsRouter(deps: TurnsRouterDeps) { const listTurnsHandler: RouteHandler = async c => { diff --git a/packages/trueforge/src/truefoundry/inlineTools.ts b/packages/trueforge/src/truefoundry/inlineTools.ts index ad27692706..add184b9a2 100644 --- a/packages/trueforge/src/truefoundry/inlineTools.ts +++ b/packages/trueforge/src/truefoundry/inlineTools.ts @@ -5,66 +5,13 @@ import { type AgentCapability, type ToolDefinition, } from '@truefoundry/trueforge-core/core'; -import { HTTPException } from 'hono/http-exception'; import { z } from 'zod'; /** Per-turn tool definitions. Not stored, so a resume turn must send the header again. */ export const X_TFG_TOOLS = 'x-tfg-tools'; -const MAX_TOOLS = 16; -const MAX_HEADER_LENGTH = 16_000; - -const InlineToolSchema = McpToolSchema; -const InlineToolsHeaderSchema = z.array(InlineToolSchema); - /** One MCP tool definition from `x-tfg-tools`. `execute` is attached later and is not part of the header. */ -export type InlineTool = z.infer; - -/** Reject the header outside TrueFoundry mode before parsing, so a bad payload does not hide the mode error. */ -export function assertTfgToolsHeaderAllowed(input: { isTrueFoundryMode: boolean }): void { - if (!input.isTrueFoundryMode) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} requires TrueFoundry mode` }); - } -} - -/** - * Tool definitions from `x-tfg-tools` for this turn. Not stored, so a resume must send the header again. - * Rejects a bad value rather than mounting a partial list. - */ -export function parseTfgTools(raw: string): readonly InlineTool[] { - if (raw.length > MAX_HEADER_LENGTH) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} is too large` }); - } - let decoded: unknown; - try { - decoded = JSON.parse(raw); - } catch (error) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array`, cause: error }); - } - if (!Array.isArray(decoded)) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array of tool definitions` }); - } - if (decoded.length > MAX_TOOLS) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} lists more than ${String(MAX_TOOLS)} tools` }); - } - - const parsed = InlineToolsHeaderSchema.safeParse(decoded); - if (!parsed.success) { - throw new HTTPException(400, { - message: schemaErrorMessage(parsed.error), - cause: parsed.error, - }); - } - - const seen = new Set(); - for (const [index, tool] of parsed.data.entries()) { - if (seen.has(tool.name)) { - throw new HTTPException(400, { message: entryMessage(index, `name "${tool.name}" is listed more than once`) }); - } - seen.add(tool.name); - } - return parsed.data; -} +export type InlineTool = z.infer; export function tfgToolsCapability(tools: readonly InlineTool[]): AgentCapability { return { @@ -96,27 +43,3 @@ class InlineClientSideTools extends ClientSideTool { return this.#tools; } } - -function schemaErrorMessage(error: z.ZodError): string { - const issue = error.issues[0]; - if (issue === undefined) { - return `${X_TFG_TOOLS} must be an array of MCP tool definitions`; - } - return `${X_TFG_TOOLS}${issuePath(issue.path)} ${issue.message}`; -} - -function issuePath(path: readonly PropertyKey[]): string { - let rendered = ''; - for (const part of path) { - if (typeof part === 'number') { - rendered += `[${String(part)}]`; - } else { - rendered += `.${String(part)}`; - } - } - return rendered; -} - -function entryMessage(index: number, detail: string): string { - return `${X_TFG_TOOLS}[${String(index)}] ${detail}`; -} diff --git a/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts b/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts index 5e51e1a18d..82e380fc03 100644 --- a/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts @@ -1,8 +1,9 @@ import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; import type { AgentInfo, ILLM } from '@truefoundry/trueforge-core/core'; import { createLogger } from 'winston'; +import { parseTfgTools } from '../../../src/apis/turns'; +import type { InlineTool } from '../../../src/truefoundry/inlineTools'; import { TrueFoundryTurnResourceResolver } from '../../../src/truefoundry/TrueFoundryTurnResourceResolver'; -import { parseTfgTools, type InlineTool } from '../../../src/truefoundry/inlineTools'; const unusedLlm: ILLM = { create: () => { diff --git a/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts index 7e886abfdf..0ee3bd59e6 100644 --- a/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts @@ -1,11 +1,7 @@ import { HTTPException } from 'hono/http-exception'; import { ZodError } from 'zod'; -import { - assertTfgToolsHeaderAllowed, - parseTfgTools, - tfgToolsCapability, - X_TFG_TOOLS, -} from '../../../src/truefoundry/inlineTools'; +import { assertTfgToolsHeaderAllowed, parseTfgTools } from '../../../src/apis/turns'; +import { tfgToolsCapability, X_TFG_TOOLS } from '../../../src/truefoundry/inlineTools'; const askUserSecret = { name: 'ask_user_secret', @@ -25,6 +21,7 @@ const askUserSecret = { secret: { type: 'string' }, }, }, + annotations: { readOnlyHint: true }, }; describe('assertTfgToolsHeaderAllowed', () => { @@ -119,6 +116,8 @@ describe('tfgToolsCapability', () => { required: ['question', 'fields'], additionalProperties: false, }); + expect(listed.result.tools[0]?.outputSchema).toEqual(askUserSecret.outputSchema); + expect(listed.result.tools[0]?.annotations).toEqual({ readOnlyHint: true }); const response = await toolSet.callTool({ name: 'ask_user_secret', arguments: {} }); if (!('clientSideToolRequired' in response)) { From df7bbf5808689b5648b685b6c8276a5e7b059e3f Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Thu, 29 Oct 2026 18:18:33 +0530 Subject: [PATCH 8/9] review comment, fix drop of output schema --- .../src/core/mcp/LocalToolMCP.ts | 11 ++-- .../tests/core/mcp/LocalToolMCP.test.ts | 62 ++++++++++++++++++- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/trueforge-core/src/core/mcp/LocalToolMCP.ts b/packages/trueforge-core/src/core/mcp/LocalToolMCP.ts index 8a0b953875..de47d4fc6c 100644 --- a/packages/trueforge-core/src/core/mcp/LocalToolMCP.ts +++ b/packages/trueforge-core/src/core/mcp/LocalToolMCP.ts @@ -90,10 +90,13 @@ export abstract class LocalToolMCP implements IToolSet { run: () => Promise.resolve({ result: { - tools: this.getTools().map(t => ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema, + tools: this.getTools().map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + // Discovery and selector policy read these from the list, not the definition. + ...(tool.outputSchema !== undefined ? { outputSchema: tool.outputSchema } : {}), + ...(tool.annotations !== undefined ? { annotations: tool.annotations } : {}), preload: true, })), }, diff --git a/packages/trueforge-core/tests/core/mcp/LocalToolMCP.test.ts b/packages/trueforge-core/tests/core/mcp/LocalToolMCP.test.ts index ef9a89e609..4c7c83e36d 100644 --- a/packages/trueforge-core/tests/core/mcp/LocalToolMCP.test.ts +++ b/packages/trueforge-core/tests/core/mcp/LocalToolMCP.test.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { toolResultResponse } from '../../../src/core/mcp/IMCPServer'; -import { defineTool } from '../../../src/core/mcp/LocalToolMCP'; +import { defineTool, LocalToolMCP, type ToolDefinition } from '../../../src/core/mcp/LocalToolMCP'; +import { NOOP_AGENT_TRACING } from '../../../src/core/tracing/NoopAgentTracing'; describe('defineTool inputSchema', () => { it('omits defaulted fields from required (input-oriented JSON Schema)', () => { @@ -33,3 +34,62 @@ describe('defineTool inputSchema', () => { expect(tool.inputSchema.required).toEqual(['message']); }); }); + +class ListedTools extends LocalToolMCP { + readonly name = 'local'; + readonly displayName = 'Local'; + + constructor(private readonly tools: ToolDefinition[]) { + super({ tracing: NOOP_AGENT_TRACING }); + } + + protected getTools(): ToolDefinition[] { + return this.tools; + } +} + +describe('LocalToolMCP listTools', () => { + it('keeps outputSchema and annotations on the listed tool', async () => { + const outputSchema = { type: 'object' as const, properties: { text: { type: 'string' } } }; + const annotations = { readOnlyHint: true }; + const server = new ListedTools([ + { + name: 'echo', + description: 'Echo', + inputSchema: { type: 'object' }, + outputSchema, + annotations, + execute: () => Promise.resolve(toolResultResponse({ text: 'ok' })), + }, + ]); + + const listed = await server.listTools(); + expect(listed.result.tools).toEqual([ + { + name: 'echo', + description: 'Echo', + inputSchema: { type: 'object' }, + outputSchema, + annotations, + preload: true, + }, + ]); + }); + + it('omits outputSchema and annotations when the definition has neither', async () => { + const server = new ListedTools([ + defineTool({ + name: 'echo', + description: 'Echo', + schema: z.object({ message: z.string() }), + handler: () => Promise.resolve(toolResultResponse({ text: 'ok' })), + }), + ]); + + const listed = await server.listTools(); + const tool = listed.result.tools[0]; + expect(tool).toBeDefined(); + expect(tool).not.toHaveProperty('outputSchema'); + expect(tool).not.toHaveProperty('annotations'); + }); +}); From bee24f3ee269219ad0ce20d4c56df05d0830c0c9 Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Thu, 29 Oct 2026 19:30:41 +0530 Subject: [PATCH 9/9] restructure to add capabilitiesStore --- .changeset/x-tfg-tools-ask-user-secret.md | 2 +- .../src/agent-session/TurnResourceResolver.ts | 18 ++ .../agent-session/rootCapabilities.test.ts | 64 ++++ packages/trueforge/scripts/write-openapi.ts | 1 + packages/trueforge/src/apis/schedules.ts | 6 +- packages/trueforge/src/apis/turns.ts | 288 +++++++----------- packages/trueforge/src/app.ts | 10 + packages/trueforge/src/main.ts | 9 + .../TrueFoundryTurnResourceResolver.ts | 25 -- .../trueforge/src/truefoundry/inlineTools.ts | 97 ++++++ .../unit/apis/deletedSessionCrud.test.ts | 1 + .../unit/apis/sandboxFileDownload.test.ts | 1 + .../tests/unit/apis/schedules.test.ts | 1 + .../tests/unit/apis/sessionGrants.test.ts | 1 + .../tests/unit/apis/sessionHttp.test.ts | 1 + .../unit/apis/turnHarnessErrorStatus.test.ts | 1 + .../trueforge/tests/unit/apis/turns.test.ts | 8 + .../TrueFoundryTurnResourceResolver.test.ts | 71 ----- .../tests/unit/truefoundry/tfgTools.test.ts | 39 ++- 19 files changed, 362 insertions(+), 282 deletions(-) create mode 100644 packages/trueforge-core/tests/agent-session/rootCapabilities.test.ts delete mode 100644 packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts delete mode 100644 packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts diff --git a/.changeset/x-tfg-tools-ask-user-secret.md b/.changeset/x-tfg-tools-ask-user-secret.md index 12121c6e5c..05beb34a2b 100644 --- a/.changeset/x-tfg-tools-ask-user-secret.md +++ b/.changeset/x-tfg-tools-ask-user-secret.md @@ -3,4 +3,4 @@ '@truefoundry/trueforge-core': patch --- -Accept `x-tfg-tools` as an array of MCP tool definitions (`inputSchema`, optional `outputSchema`) on a TrueFoundry-mode turn, and keep `outputSchema` and `annotations` when those local tools are listed. +Accept `x-tfg-tools` as an array of MCP tool definitions (`inputSchema`, optional `outputSchema`) on a TrueFoundry-mode turn. The header is read by a per-request store and mounted on the main thread through `TurnResourceResolver`. Local tool lists keep `outputSchema` and `annotations`. diff --git a/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts b/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts index 586f645db9..4ed70f3958 100644 --- a/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts +++ b/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts @@ -1,4 +1,5 @@ import type { Logger } from 'winston'; +import type { AgentCapability } from '../core/capabilities/AgentCapability'; import type { ILLM } from '../core/llm/ILLM'; import type { ModelParams } from '../core/llm/model'; import type { VercelAIProviderName } from '../core/llm/VercelAILLM'; @@ -33,6 +34,16 @@ function specWantsSandbox(spec: AgentSpec): boolean { return spec.config.sandbox.enabled; } +function rootCapabilitiesOf( + rootCapabilities: (() => readonly AgentCapability[]) | undefined, +): { extraCapabilities: AgentCapability[] } | Record { + const capabilities = rootCapabilities?.() ?? []; + if (capabilities.length === 0) { + return {}; + } + return { extraCapabilities: [...capabilities] }; +} + function toSelectors(entry: { enable_tools: string[]; disable_tools: string[]; @@ -88,6 +99,12 @@ export class TurnResourceResolver< agent?: ((agentId: string) => Promise) | undefined; /** Host web-search backend; omit when not configured. */ webSearchProvider?: IWebSearchProvider | undefined; + /** + * Main-thread capabilities. Called once per root resolve, so each call gets fresh instances. + * A child thread passes `agent_info` and does not receive these. A subclass that replaces + * `resolveAgentDefinition` without calling `super` does not mount them. + */ + rootCapabilities?: (() => readonly AgentCapability[]) | undefined; /** Forwarded to RemoteMCP / AgentThread (required by their constructors). */ logger: Logger; }, @@ -237,6 +254,7 @@ export class TurnResourceResolver< iterationLimit: spec.config.iteration_limit, toolSets, }, + ...(agentInfo === undefined ? rootCapabilitiesOf(this.deps.rootCapabilities) : {}), }; } diff --git a/packages/trueforge-core/tests/agent-session/rootCapabilities.test.ts b/packages/trueforge-core/tests/agent-session/rootCapabilities.test.ts new file mode 100644 index 0000000000..cd23ced25d --- /dev/null +++ b/packages/trueforge-core/tests/agent-session/rootCapabilities.test.ts @@ -0,0 +1,64 @@ +import { TurnResourceResolver } from '../../src/agent-session/TurnResourceResolver'; +import type { AgentCapability, AgentInfo } from '../../src/core'; +import { makeAgentSpec, makeMockILLM, makeSilentLogger } from './testHelpers'; + +const child: AgentInfo = { type: 'dynamic', name: 'child', input: 'do the task' }; + +function resolverWith(rootCapabilities?: () => readonly AgentCapability[]): TurnResourceResolver { + return new TurnResourceResolver({ + llm: () => + Promise.resolve({ + modelClient: makeMockILLM(), + defaultModelParams: {}, + providerType: undefined, + }), + mcp: () => Promise.reject(new Error('unused')), + mcpRequestTimeoutMs: 1_000, + mcpConnectTimeoutMs: 1_000, + logger: makeSilentLogger(), + ...(rootCapabilities === undefined ? {} : { rootCapabilities }), + }); +} + +describe('TurnResourceResolver rootCapabilities', () => { + it('mounts them on the main thread and skips child threads', async () => { + const capability: AgentCapability = {}; + let calls = 0; + const resolver = resolverWith(() => { + calls += 1; + return [capability]; + }); + const tracing = resolver.createTracing(); + const signal = new AbortController().signal; + const spec = makeAgentSpec(); + + const root = await resolver.resolveAgentDefinition({ spec, signal, tracing }); + const nested = await resolver.resolveAgentDefinition({ spec, signal, tracing, agent_info: child }); + + expect(root.extraCapabilities).toEqual([capability]); + expect(nested.extraCapabilities).toBeUndefined(); + expect(calls).toBe(1); + }); + + it('omits extraCapabilities when the list is empty', async () => { + const resolver = resolverWith(() => []); + const resolved = await resolver.resolveAgentDefinition({ + spec: makeAgentSpec(), + signal: new AbortController().signal, + tracing: resolver.createTracing(), + }); + + expect(resolved.extraCapabilities).toBeUndefined(); + }); + + it('omits extraCapabilities when none are configured', async () => { + const resolver = resolverWith(); + const resolved = await resolver.resolveAgentDefinition({ + spec: makeAgentSpec(), + signal: new AbortController().signal, + tracing: resolver.createTracing(), + }); + + expect(resolved.extraCapabilities).toBeUndefined(); + }); +}); diff --git a/packages/trueforge/scripts/write-openapi.ts b/packages/trueforge/scripts/write-openapi.ts index 195a413eef..c2210c02ec 100644 --- a/packages/trueforge/scripts/write-openapi.ts +++ b/packages/trueforge/scripts/write-openapi.ts @@ -89,6 +89,7 @@ const app = createServerApp({ }), resolveSkillStore: () => skillStore, resolveTurnSkillsStore: () => skillStore, + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveSandboxProviderStore: () => sandboxProviderStore, resolveWebSearchProviderStore: () => webSearchProviderStore, resolveAgentStore: () => agentStore, diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index f7978e01a1..8a77959acf 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -10,7 +10,7 @@ import { import type { Context } from 'hono'; import { HTTPException } from 'hono/http-exception'; import type { Logger } from 'winston'; -import type { ResolveTurnSkillsStoreInput } from '../app'; +import type { ResolveCapabilitiesStoreInput, ResolveTurnSkillsStoreInput } from '../app'; import type { Authorizer } from '../auth/authorizer'; import { canEditSchedule, @@ -65,6 +65,7 @@ import { type ScheduleManifest, type ScheduleRun, } from '../schemas/schedule'; +import type { ICapabilitiesStore } from '../truefoundry/inlineTools'; import { agentIfAccessible, canReadAgentBoundResource, resolveManagedAgentIds } from './agentAccess'; import { getTurnExecutionError, startTurnInProcess } from './turns'; @@ -84,6 +85,8 @@ export interface ScheduleTurnExecutionDeps { agentStore: IAgentStore; /** Call with `{}` — schedules have no caller `x-tfg-skills`. */ resolveTurnSkillsStore: (input: ResolveTurnSkillsStoreInput) => Pick; + /** Call with `{}` — schedules have no caller `x-tfg-tools`. */ + resolveCapabilitiesStore: (input: ResolveCapabilitiesStoreInput) => ICapabilitiesStore; } export interface SchedulesRouterDeps extends ScheduleTurnExecutionDeps { @@ -127,6 +130,7 @@ export async function startScheduleRunOnRequest(params: { sandboxEnvironmentStore: deps.sandboxEnvironmentStore, webSearchProviderStore: deps.resolveWebSearchProviderStore(c), skillStore: deps.resolveTurnSkillsStore({}), + capabilitiesStore: deps.resolveCapabilitiesStore({}), logger: deps.logger, }, }); diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 9f0c10adb3..71e9c5726f 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -2,18 +2,17 @@ * DB-backed turns API (mounted at /api/v1/sessions). */ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; -import { ToolSchema as McpToolSchema } from '@modelcontextprotocol/sdk/types.js'; import type { ISessionStore, Sessions, Turn, TurnStreamingEvent } from '@truefoundry/trueforge-core/agent-session'; import { CancellationReason, EventType, SessionStoreConflictError, SessionStoreNotFoundError, + TurnResourceResolver, type SessionHandle, type TurnHandle, type TurnInputItem, type TurnRecordWithoutSnapshot, - type TurnResourceResolver, } from '@truefoundry/trueforge-core/agent-session'; import type { IWebSearchProvider } from '@truefoundry/trueforge-core/core'; import { @@ -36,11 +35,15 @@ import type { Context } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { streamSSE } from 'hono/streaming'; import type { Logger } from 'winston'; -import { z } from 'zod'; -import type { ResolveMcpServerStoreInput, ResolveModelProviderStoreInput, ResolveTurnSkillsStoreInput } from '../app'; +import type { + ResolveCapabilitiesStoreInput, + ResolveMcpServerStoreInput, + ResolveModelProviderStoreInput, + ResolveTurnSkillsStoreInput, +} from '../app'; import type { Authorizer } from '../auth/authorizer'; import type { ResolveRequestContext } from '../auth/identity'; -import configuration, { isTrueFoundryModeEnabled } from '../config'; +import configuration from '../config'; import type { IAccessControlStore } from '../db/accessControlStore'; import type { AgentRecord, IAgentStore } from '../db/agentStore'; import type { IMcpServerWithAuthStore } from '../db/mcpServerStore'; @@ -73,8 +76,7 @@ import { import { DEFAULT_SANDBOX_ENVIRONMENT_NAME } from '../schemas/sandboxEnvironment'; import { MAX_SESSION_TITLE_LENGTH } from '../schemas/session'; import { assertGatewayMetadataRequestHeaders } from '../truefoundry/gatewayMetadata'; -import { X_TFG_TOOLS, type InlineTool } from '../truefoundry/inlineTools'; -import { TrueFoundryTurnResourceResolver } from '../truefoundry/TrueFoundryTurnResourceResolver'; +import type { ICapabilitiesStore } from '../truefoundry/inlineTools'; import { newId } from '../utils/id'; import { resolveWebSearchProvider } from '../websearch/providers'; import { canReadAgentBoundResource, canReadSession } from './agentAccess'; @@ -129,6 +131,8 @@ export interface TurnsRouterDeps { resolveModelProviderStore: (input: ResolveModelProviderStoreInput) => IModelProviderStore; resolveMcpServerStore: (input: ResolveMcpServerStoreInput) => IMcpServerWithAuthStore; resolveTurnSkillsStore: (input: ResolveTurnSkillsStoreInput) => Pick; + /** `x-tfg-tools` for this turn. Schedules omit `c` and mount nothing. */ + resolveCapabilitiesStore: (input: ResolveCapabilitiesStoreInput) => ICapabilitiesStore; resolveAgentStore: (c: Context) => IAgentStore; /** Resumable live turn-event transport: create-turn writes, subscribe polls. */ eventSubscriptions: EventSubscriptionRegistry; @@ -155,6 +159,7 @@ export type BeginTurnExecutionDeps = Pick< sandboxProviderStore: ISandboxProviderStore; webSearchProviderStore: IWebSearchProviderStore; skillStore: Pick; + capabilitiesStore: ICapabilitiesStore; }; interface BeginTurnExecutionParams { @@ -164,8 +169,6 @@ interface BeginTurnExecutionParams { userRef: string; /** Raw inbound request headers. Absent for a schedule run. */ requestHeaders?: Record; - /** Tool definitions from `x-tfg-tools`. Absent on schedule runs, which have no request headers. */ - tfgTools?: readonly InlineTool[]; deps: BeginTurnExecutionDeps; } @@ -186,7 +189,7 @@ function createTurnResolver(deps: { userRef: string; session: SessionHandle; turnMetadata: TurnMetadata; - tfgTools: readonly InlineTool[]; + capabilitiesStore: ICapabilitiesStore; }): TurnResourceResolver { const { mcpServerStore, @@ -201,110 +204,108 @@ function createTurnResolver(deps: { userRef, session, turnMetadata, - tfgTools, + capabilitiesStore, } = deps; const tenant_id = session.tenant_id; const sessionId = session.session_id; - return new TrueFoundryTurnResourceResolver({ - tools: tfgTools, - deps: { - llm: async name => { - const resolved = await getModelDetails({ - tenant_id, - name, - store: modelProviderStore, - turnMetadata, + return new TurnResourceResolver({ + llm: async name => { + const resolved = await getModelDetails({ + tenant_id, + name, + store: modelProviderStore, + turnMetadata, + }); + return { + modelClient: new VercelAILLM({ + providerConfig: resolved.providerConfig, + logger, + signal, + }), + defaultModelParams: resolved.defaultModelParams, + modelProperties: resolved.modelProperties, + providerType: resolved.providerConfig.provider.type, + }; + }, + mcp: async name => { + const connection = await getMcpConnection({ + tenant_id, + name, + store: mcpServerStore, + userRef, + turnMetadata, + }); + if (connection === undefined) { + throw new HTTPException(422, { + message: `Unknown MCP server "${name}" — not configured`, }); - return { - modelClient: new VercelAILLM({ - providerConfig: resolved.providerConfig, - logger, - signal, - }), - defaultModelParams: resolved.defaultModelParams, - modelProperties: resolved.modelProperties, - providerType: resolved.providerConfig.provider.type, - }; - }, - mcp: async name => { - const connection = await getMcpConnection({ - tenant_id, - name, - store: mcpServerStore, - userRef, - turnMetadata, + } + return connection; + }, + mcpRequestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, + mcpConnectTimeoutMs: configuration.MCP_CONNECT_TIMEOUT_MS, + mcpMaxResponseBytes: configuration.MCP_TOOL_CALL_MAX_RESPONSE_BYTES, + sandboxProvider: async ({ spec, existingSandboxId, tracing }) => { + const provider = await resolveSandboxProvider({ + tenant_id, + store: sandboxProviderStore, + logger, + sessionId, + }); + if (provider === undefined) { + throw new HTTPException(422, { + message: 'no sandbox provider configured — PUT /settings/sandbox-providers', }); - if (connection === undefined) { - throw new HTTPException(422, { - message: `Unknown MCP server "${name}" — not configured`, - }); - } - return connection; - }, - mcpRequestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, - mcpConnectTimeoutMs: configuration.MCP_CONNECT_TIMEOUT_MS, - mcpMaxResponseBytes: configuration.MCP_TOOL_CALL_MAX_RESPONSE_BYTES, - sandboxProvider: async ({ spec, existingSandboxId, tracing }) => { - const provider = await resolveSandboxProvider({ + } + const skills = spec.skills ?? []; + const mountSkills = + skills.length === 0 + ? [] + : await skillStore.resolveTurnSkills({ + tenant_id, + skills, + }); + const carriedSandboxId = existingSandboxIdForProvider({ + existingSandboxId, + currentProviderType: provider.type, + }); + const turnSandboxBase = { + logger, + skills: mountSkills, + fileDownloadEnabled: spec.config.sandbox.file_downloads, + existingSandboxId: carriedSandboxId, + tracing, + }; + // Env-capable providers (Daytona): resolve env. Others skip (no snapshot concept). + if (provider.envSupported) { + const environment = await resolveSandboxEnvironment({ tenant_id, - store: sandboxProviderStore, - logger, - sessionId, + name: spec.config.sandbox.environment_name ?? DEFAULT_SANDBOX_ENVIRONMENT_NAME, + sandboxEnvironmentStore, + optional: spec.config.sandbox.environment_name === undefined, }); - if (provider === undefined) { - throw new HTTPException(422, { - message: 'no sandbox provider configured — PUT /settings/sandbox-providers', - }); - } - const skills = spec.skills ?? []; - const mountSkills = - skills.length === 0 - ? [] - : await skillStore.resolveTurnSkills({ - tenant_id, - skills, - }); - const carriedSandboxId = existingSandboxIdForProvider({ - existingSandboxId, - currentProviderType: provider.type, - }); - const turnSandboxBase = { - logger, - skills: mountSkills, - fileDownloadEnabled: spec.config.sandbox.file_downloads, - existingSandboxId: carriedSandboxId, - tracing, - }; - // Env-capable providers (Daytona): resolve env. Others skip (no snapshot concept). - if (provider.envSupported) { - const environment = await resolveSandboxEnvironment({ - tenant_id, - name: spec.config.sandbox.environment_name ?? DEFAULT_SANDBOX_ENVIRONMENT_NAME, - sandboxEnvironmentStore, - optional: spec.config.sandbox.environment_name === undefined, - }); - return buildTurnSandbox({ - provider, - ...(environment !== undefined ? { environment } : {}), - ...turnSandboxBase, - }); - } return buildTurnSandbox({ provider, + ...(environment !== undefined ? { environment } : {}), ...turnSandboxBase, }); - }, - agent: async agentId => { - const record = await agentStore.getAgent({ tenant_id, id: agentId }); - if (record === undefined) { - throw new HTTPException(422, { message: `Agent not found: ${agentId}` }); - } - return record.manifest; - }, - webSearchProvider, - logger, + } + return buildTurnSandbox({ + provider, + ...turnSandboxBase, + }); }, + agent: async agentId => { + const record = await agentStore.getAgent({ tenant_id, id: agentId }); + if (record === undefined) { + throw new HTTPException(422, { message: `Agent not found: ${agentId}` }); + } + return record.manifest; + }, + webSearchProvider, + logger, + rootCapabilities: () => capabilitiesStore.resolveCapabilities(), }); } @@ -466,7 +467,7 @@ export interface TurnEventDrainInput { export async function beginTurnExecution( params: BeginTurnExecutionParams, ): Promise<{ turn: TurnHandle; drainInput: TurnEventDrainInput }> { - const { session, input, previous_turn_id: previousTurnId, userRef, requestHeaders, tfgTools, deps } = params; + const { session, input, previous_turn_id: previousTurnId, userRef, requestHeaders, deps } = params; // Fail closed on a bad inbound header before minting a turn id / starting execution. assertGatewayMetadataRequestHeaders(requestHeaders); const sessionId = session.session_id; @@ -503,7 +504,7 @@ export async function beginTurnExecution( ...(sessionAgent.type === 'reference' ? { agent: { id: sessionAgent.id, name: sessionAgent.name } } : {}), ...(requestHeaders === undefined ? {} : { requestHeaders }), }, - tfgTools: tfgTools ?? [], + capabilitiesStore: deps.capabilitiesStore, }); // First turn only: derive the title from the first user message. The store @@ -639,78 +640,6 @@ function isSessionOwner({ const FORBIDDEN_SESSION_ACCESS = 'Only the session creator can access this session'; const FORBIDDEN_CREATE_TURN = 'Only the session creator can create turns'; -const MAX_TFG_TOOLS = 16; -const MAX_TFG_TOOLS_HEADER_LENGTH = 16_000; -const InlineToolsHeaderSchema = z.array(McpToolSchema); - -/** Reject the header outside TrueFoundry mode before parsing, so a bad payload does not hide the mode error. */ -export function assertTfgToolsHeaderAllowed(input: { isTrueFoundryMode: boolean }): void { - if (!input.isTrueFoundryMode) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} requires TrueFoundry mode` }); - } -} - -/** - * Tool definitions from `x-tfg-tools` for this turn. Not stored, so a resume must send the header again. - * Rejects a bad value rather than mounting a partial list. - */ -export function parseTfgTools(raw: string): readonly InlineTool[] { - if (raw.length > MAX_TFG_TOOLS_HEADER_LENGTH) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} is too large` }); - } - let decoded: unknown; - try { - decoded = JSON.parse(raw); - } catch (error) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array`, cause: error }); - } - if (!Array.isArray(decoded)) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array of tool definitions` }); - } - if (decoded.length > MAX_TFG_TOOLS) { - throw new HTTPException(400, { message: `${X_TFG_TOOLS} lists more than ${String(MAX_TFG_TOOLS)} tools` }); - } - - const parsed = InlineToolsHeaderSchema.safeParse(decoded); - if (!parsed.success) { - throw new HTTPException(400, { - message: tfgToolsSchemaErrorMessage(parsed.error), - cause: parsed.error, - }); - } - - const seen = new Set(); - for (const [index, tool] of parsed.data.entries()) { - if (seen.has(tool.name)) { - throw new HTTPException(400, { - message: `${X_TFG_TOOLS}[${String(index)}] name "${tool.name}" is listed more than once`, - }); - } - seen.add(tool.name); - } - return parsed.data; -} - -function tfgToolsSchemaErrorMessage(error: z.ZodError): string { - const issue = error.issues[0]; - if (issue === undefined) { - return `${X_TFG_TOOLS} must be an array of MCP tool definitions`; - } - return `${X_TFG_TOOLS}${tfgToolsIssuePath(issue.path)} ${issue.message}`; -} - -function tfgToolsIssuePath(path: readonly PropertyKey[]): string { - let rendered = ''; - for (const part of path) { - if (typeof part === 'number') { - rendered += `[${String(part)}]`; - } else { - rendered += `.${String(part)}`; - } - } - return rendered; -} - /** DB-backed turns (mounted at /api/v1/sessions). */ export function createTurnsRouter(deps: TurnsRouterDeps) { const listTurnsHandler: RouteHandler = async c => { @@ -935,19 +864,12 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { // x-tfg-skills / x-tfg-mcp / x-tfg-models on turns only when the session agent is inline. const allowInlineResources = session.record.agent.type === 'inline'; - // x-tfg-tools is per turn and is not stored. A resume must send it again. - const rawTfgTools = c.req.header(X_TFG_TOOLS); - if (rawTfgTools !== undefined) { - assertTfgToolsHeaderAllowed({ isTrueFoundryMode: isTrueFoundryModeEnabled(configuration) }); - } - const tfgTools = rawTfgTools === undefined ? undefined : parseTfgTools(rawTfgTools); const turnParams: BeginTurnExecutionParams = { session, input: body.input, previous_turn_id: body.previous_turn_id, userRef: requestContext.subject.id, requestHeaders: c.req.header(), - ...(tfgTools === undefined ? {} : { tfgTools }), deps: { ...deps, modelProviderStore: deps.resolveModelProviderStore({ @@ -961,6 +883,8 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), }), skillStore: deps.resolveTurnSkillsStore({ c, allowInlineResources }), + // Parsed here, before a turn id is minted. + capabilitiesStore: deps.resolveCapabilitiesStore({ c }), agentStore: deps.resolveAgentStore(c), sandboxProviderStore: deps.resolveSandboxProviderStore(c), sandboxEnvironmentStore: deps.sandboxEnvironmentStore, diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index f9c48ca129..80372281e4 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -63,6 +63,7 @@ import { OPENAPI_DOCUMENT_TAGS } from './routes/openapiTags'; import type { ActiveTurnRegistry } from './runtime/activeTurns'; import type { EventSubscriptionRegistry } from './runtime/event-subscription'; import { InvalidCronError } from './schemas/schedule'; +import type { ICapabilitiesStore } from './truefoundry/inlineTools'; import { zodErrorResponse, zodValidationHook } from './zodErrorResponse'; const BEARER_AUTH_SCHEME = 'BearerAuth'; @@ -266,6 +267,11 @@ export interface ResolveTurnSkillsStoreInput { allowInlineResources?: boolean; } +/** Turn capability resolve. Omit `c` for schedules, which have no `x-tfg-tools`. */ +export interface ResolveCapabilitiesStoreInput { + c?: Context; +} + /** HTTP skill-store resolve. `allowInlineResources` for inline session create and spec replace. */ export interface ResolveSkillStoreInput { c: Context; @@ -300,6 +306,8 @@ export interface ServerDeps { * schedules call with `{}` (registry/DB only). */ resolveTurnSkillsStore: (input: ResolveTurnSkillsStoreInput) => Pick, 'resolveTurnSkills'>; + /** `x-tfg-tools` for this turn. Schedules call with `{}` and mount nothing. */ + resolveCapabilitiesStore: (input: ResolveCapabilitiesStoreInput) => ICapabilitiesStore; withTransaction: WithTransaction; tokenStore: IOAuthTokenStore; scheduleStore: IScheduleStore; @@ -355,6 +363,7 @@ export function createServerApp(deps: ServerDeps) { resolveWebSearchProviderStore: deps.resolveWebSearchProviderStore, activeTurns: deps.activeTurns, resolveTurnSkillsStore: deps.resolveTurnSkillsStore, + resolveCapabilitiesStore: deps.resolveCapabilitiesStore, }; const httpMetrics = deps.httpMetrics; @@ -619,6 +628,7 @@ export function createServerApp(deps: ServerDeps) { resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, resolveTurnSkillsStore: deps.resolveTurnSkillsStore, + resolveCapabilitiesStore: deps.resolveCapabilitiesStore, resolveAgentStore: deps.resolveAgentStore, eventSubscriptions: deps.eventSubscriptions, resolveSandboxProviderStore: deps.resolveSandboxProviderStore, diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index aaf8033370..49bf1aee82 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -96,6 +96,7 @@ import type { Logger } from 'winston'; import { createServerApp, + type ResolveCapabilitiesStoreInput, type ResolveMcpServerStoreInput, type ResolveModelProviderStoreInput, type ResolveSkillStoreInput, @@ -157,6 +158,7 @@ import { type InlineSkills, } from './truefoundry/inlineResources'; import { InlineSkillStore } from './truefoundry/InlineSkillStore'; +import { capabilitiesStoreFromHeader, X_TFG_TOOLS } from './truefoundry/inlineTools'; import { parsePerServerMcpHeaders, X_TFG_MCP_HEADERS, @@ -789,6 +791,12 @@ async function createServerRuntime(persistence: ServerPersistence< rawInlineSkillsHeader === undefined ? {} : { inlineSkills: parseInlineSkills(rawInlineSkillsHeader) }, ); }; + // x-tfg-tools is per turn and is not stored. Schedules omit `c` and mount nothing. + const resolveCapabilitiesStore = (input: ResolveCapabilitiesStoreInput) => + capabilitiesStoreFromHeader({ + header: input.c?.req.header(X_TFG_TOOLS), + isTrueFoundryMode, + }); const app = createServerApp({ modelCatalog: ModelCatalog.load(), mcpCatalog: McpCatalog.load(), @@ -802,6 +810,7 @@ async function createServerRuntime(persistence: ServerPersistence< resolveWebSearchProviderStore, resolveSkillStore, resolveTurnSkillsStore: resolveTurnSkillsStoreForRequest, + resolveCapabilitiesStore, withTransaction, tokenStore, scheduleStore, diff --git a/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts b/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts deleted file mode 100644 index 94dcc6bc6b..0000000000 --- a/packages/trueforge/src/truefoundry/TrueFoundryTurnResourceResolver.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { TurnResourceResolver } from '@truefoundry/trueforge-core/agent-session'; -import { tfgToolsCapability, type InlineTool } from './inlineTools'; - -type TurnResolverDeps = ConstructorParameters[0]; - -/** Root-only mount for `x-tfg-tools`. A child thread passes `agent_info` and does not get the tools. */ -export class TrueFoundryTurnResourceResolver extends TurnResourceResolver { - readonly #tools: readonly InlineTool[]; - - constructor(input: { deps: TurnResolverDeps; tools: readonly InlineTool[] }) { - super(input.deps); - this.#tools = input.tools; - } - - override async resolveAgentDefinition(input: Parameters[0]) { - const resolved = await super.resolveAgentDefinition(input); - if (input.agent_info !== undefined || this.#tools.length === 0) { - return resolved; - } - return { - ...resolved, - extraCapabilities: [...(resolved.extraCapabilities ?? []), tfgToolsCapability(this.#tools)], - }; - } -} diff --git a/packages/trueforge/src/truefoundry/inlineTools.ts b/packages/trueforge/src/truefoundry/inlineTools.ts index add184b9a2..0f5845e7f2 100644 --- a/packages/trueforge/src/truefoundry/inlineTools.ts +++ b/packages/trueforge/src/truefoundry/inlineTools.ts @@ -5,20 +5,117 @@ import { type AgentCapability, type ToolDefinition, } from '@truefoundry/trueforge-core/core'; +import { HTTPException } from 'hono/http-exception'; import { z } from 'zod'; /** Per-turn tool definitions. Not stored, so a resume turn must send the header again. */ export const X_TFG_TOOLS = 'x-tfg-tools'; +const MAX_TFG_TOOLS = 16; +const MAX_TFG_TOOLS_HEADER_LENGTH = 16_000; +const InlineToolsHeaderSchema = z.array(McpToolSchema); + /** One MCP tool definition from `x-tfg-tools`. `execute` is attached later and is not part of the header. */ export type InlineTool = z.infer; +/** Main-thread capabilities for one turn. Built before the turn id exists. */ +export interface ICapabilitiesStore { + /** Fresh instances on every call. Empty when this turn mounts no header tools. */ + resolveCapabilities(): AgentCapability[]; +} + +/** Reject the header outside TrueFoundry mode before parsing, so a bad payload does not hide the mode error. */ +export function assertTfgToolsHeaderAllowed(input: { isTrueFoundryMode: boolean }): void { + if (!input.isTrueFoundryMode) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} requires TrueFoundry mode` }); + } +} + +/** + * Tool definitions from `x-tfg-tools` for this turn. Not stored, so a resume must send the header again. + * Rejects a bad value rather than mounting a partial list. + */ +export function parseTfgTools(raw: string): readonly InlineTool[] { + if (raw.length > MAX_TFG_TOOLS_HEADER_LENGTH) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} is too large` }); + } + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch (error) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array`, cause: error }); + } + if (!Array.isArray(decoded)) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} must be a JSON array of tool definitions` }); + } + if (decoded.length > MAX_TFG_TOOLS) { + throw new HTTPException(400, { message: `${X_TFG_TOOLS} lists more than ${String(MAX_TFG_TOOLS)} tools` }); + } + + const parsed = InlineToolsHeaderSchema.safeParse(decoded); + if (!parsed.success) { + throw new HTTPException(400, { + message: tfgToolsSchemaErrorMessage(parsed.error), + cause: parsed.error, + }); + } + + const seen = new Set(); + for (const [index, tool] of parsed.data.entries()) { + if (seen.has(tool.name)) { + throw new HTTPException(400, { + message: `${X_TFG_TOOLS}[${String(index)}] name "${tool.name}" is listed more than once`, + }); + } + seen.add(tool.name); + } + return parsed.data; +} + +/** + * Parse `x-tfg-tools` immediately, so a bad header fails before a turn id exists. + * No header (a schedule run passes none) mounts nothing. + */ +export function capabilitiesStoreFromHeader(input: { + header: string | undefined; + isTrueFoundryMode: boolean; +}): ICapabilitiesStore { + if (input.header === undefined) { + return { resolveCapabilities: () => [] }; + } + assertTfgToolsHeaderAllowed({ isTrueFoundryMode: input.isTrueFoundryMode }); + const tools = parseTfgTools(input.header); + return { + resolveCapabilities: () => (tools.length === 0 ? [] : [tfgToolsCapability(tools)]), + }; +} + export function tfgToolsCapability(tools: readonly InlineTool[]): AgentCapability { return { systemToolSets: [new InlineClientSideTools(tools)], }; } +function tfgToolsSchemaErrorMessage(error: z.ZodError): string { + const issue = error.issues[0]; + if (issue === undefined) { + return `${X_TFG_TOOLS} must be an array of MCP tool definitions`; + } + return `${X_TFG_TOOLS}${tfgToolsIssuePath(issue.path)} ${issue.message}`; +} + +function tfgToolsIssuePath(path: readonly PropertyKey[]): string { + let rendered = ''; + for (const part of path) { + if (typeof part === 'number') { + rendered += `[${String(part)}]`; + } else { + rendered += `.${String(part)}`; + } + } + return rendered; +} + class InlineClientSideTools extends ClientSideTool { readonly name = X_TFG_TOOLS; readonly displayName = 'TfgTools'; diff --git a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts index cff74638d3..cbcd3d90a1 100644 --- a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts +++ b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts @@ -79,6 +79,7 @@ describe('public CRUD after session deletion', () => { resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => mcpServerStore, resolveTurnSkillsStore: () => skillStore, + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveAgentStore: () => agentStore, eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => sandboxProviderStore, diff --git a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts index e2d68e7dfc..61f7689089 100644 --- a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts @@ -52,6 +52,7 @@ async function buildApp() { clientName: 'test-client', }), resolveTurnSkillsStore: () => new SqliteSkillStore(db), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), diff --git a/packages/trueforge/tests/unit/apis/schedules.test.ts b/packages/trueforge/tests/unit/apis/schedules.test.ts index 5342c03558..117de4b1c9 100644 --- a/packages/trueforge/tests/unit/apis/schedules.test.ts +++ b/packages/trueforge/tests/unit/apis/schedules.test.ts @@ -71,6 +71,7 @@ function stubTurnExecutionDeps(agentStore: SqliteAgentStore, scheduleStore: Sqli resolveModelProviderStore: () => ({}) as never, resolveMcpServerStore: () => ({}) as never, resolveTurnSkillsStore: () => ({ resolveTurnSkills: async () => [] }), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveSandboxProviderStore: () => ({}) as never, sandboxEnvironmentStore: {} as never, resolveWebSearchProviderStore: () => ({}) as never, diff --git a/packages/trueforge/tests/unit/apis/sessionGrants.test.ts b/packages/trueforge/tests/unit/apis/sessionGrants.test.ts index 7bb0b851b4..bf1b493e7d 100644 --- a/packages/trueforge/tests/unit/apis/sessionGrants.test.ts +++ b/packages/trueforge/tests/unit/apis/sessionGrants.test.ts @@ -111,6 +111,7 @@ function buildApp(input: { db: Kysely; sessionStore: SqliteSessionStor clientName: 'test-client', }), resolveTurnSkillsStore: () => new SqliteSkillStore(db), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveAgentStore: () => agentStore, eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), diff --git a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts index ed4aedc0c1..274f73b3be 100644 --- a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts +++ b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts @@ -157,6 +157,7 @@ describe('sessions HTTP agent binding', () => { clientName: 'test-client', }), resolveTurnSkillsStore: () => skillStore, + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveAgentStore: () => agentStore, eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => sandboxProviderStore, diff --git a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts index 63145c98c3..7b53d3f6ba 100644 --- a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts +++ b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts @@ -81,6 +81,7 @@ async function postTurnRejectingWith(error: AgentHarnessError): Promise new SqliteSkillStore(db), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), diff --git a/packages/trueforge/tests/unit/apis/turns.test.ts b/packages/trueforge/tests/unit/apis/turns.test.ts index b0643a9edc..98786684dd 100644 --- a/packages/trueforge/tests/unit/apis/turns.test.ts +++ b/packages/trueforge/tests/unit/apis/turns.test.ts @@ -127,6 +127,7 @@ describe('turns', () => { resolveModelProviderStore: () => new SqliteModelProviderStore(db), resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), resolveTurnSkillsStore: () => new SqliteSkillStore(db), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), @@ -224,6 +225,7 @@ describe('turns', () => { resolveModelProviderStore: () => new SqliteModelProviderStore(db), resolveMcpServerStore: () => mcpServerStoreWithAuth(db, new SqliteOAuthTokenStore(db)), resolveTurnSkillsStore: () => new SqliteSkillStore(db), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveAgentStore: () => agentStore, eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), @@ -325,6 +327,7 @@ describe('turns', () => { resolveModelProviderStore: () => new SqliteModelProviderStore(db), resolveMcpServerStore: () => mcpServerStoreWithAuth(db, new SqliteOAuthTokenStore(db)), resolveTurnSkillsStore: () => new SqliteSkillStore(db), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), @@ -397,6 +400,7 @@ describe('turns', () => { resolveModelProviderStore: () => new SqliteModelProviderStore(db), resolveMcpServerStore: () => mcpServerStoreWithAuth(db, new SqliteOAuthTokenStore(db)), resolveTurnSkillsStore: () => new SqliteSkillStore(db), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), @@ -507,6 +511,7 @@ describe('turns', () => { resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), resolveAgentStore: () => new SqliteAgentStore(db), resolveTurnSkillsStore: () => new SqliteSkillStore(db), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), eventSubscriptions, resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), sandboxEnvironmentStore: new SqliteSandboxEnvironmentStore(db), @@ -639,6 +644,7 @@ describe('turns', () => { resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), resolveAgentStore: () => new SqliteAgentStore(db), resolveTurnSkillsStore: () => new SqliteSkillStore(db), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), sandboxEnvironmentStore: new SqliteSandboxEnvironmentStore(db), @@ -758,6 +764,7 @@ describe('turns', () => { resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), resolveTurnSkillsStore: () => new SqliteSkillStore(db), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), @@ -846,6 +853,7 @@ describe('turns', () => { resolveModelProviderStore: () => new SqliteModelProviderStore(db), resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), resolveTurnSkillsStore: () => new SqliteSkillStore(db), + resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }), resolveAgentStore: () => agentStore, eventSubscriptions: new EventSubscriptionRegistry(undefined), resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), diff --git a/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts b/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts deleted file mode 100644 index 82e380fc03..0000000000 --- a/packages/trueforge/tests/unit/truefoundry/TrueFoundryTurnResourceResolver.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; -import type { AgentInfo, ILLM } from '@truefoundry/trueforge-core/core'; -import { createLogger } from 'winston'; -import { parseTfgTools } from '../../../src/apis/turns'; -import type { InlineTool } from '../../../src/truefoundry/inlineTools'; -import { TrueFoundryTurnResourceResolver } from '../../../src/truefoundry/TrueFoundryTurnResourceResolver'; - -const unusedLlm: ILLM = { - create: () => { - throw new Error('unused'); - }, - createNonStream: () => Promise.reject(new Error('unused')), -}; - -const askUserSecret = parseTfgTools( - JSON.stringify([ - { - name: 'ask_user_secret', - description: 'Collect a secret.', - inputSchema: { type: 'object', properties: { question: { type: 'string' } }, required: ['question'] }, - }, - ]), -); - -function resolverWith(tools: readonly InlineTool[]): TrueFoundryTurnResourceResolver { - return new TrueFoundryTurnResourceResolver({ - tools, - deps: { - llm: () => - Promise.resolve({ - modelClient: unusedLlm, - defaultModelParams: {}, - providerType: undefined, - }), - mcp: () => Promise.reject(new Error('unused')), - mcpRequestTimeoutMs: 1000, - mcpConnectTimeoutMs: 1000, - logger: createLogger({ silent: true }), - }, - }); -} - -const spec = AgentSpecSchema.parse({ model: { name: 'provider/model' } }); -const child: AgentInfo = { type: 'dynamic', name: 'child', input: 'do the task' }; - -describe('TrueFoundryTurnResourceResolver', () => { - it('mounts header tools on the root thread only', async () => { - const resolver = resolverWith(askUserSecret); - const tracing = resolver.createTracing(); - const signal = new AbortController().signal; - - const root = await resolver.resolveAgentDefinition({ spec, signal, tracing }); - const nested = await resolver.resolveAgentDefinition({ spec, signal, tracing, agent_info: child }); - - expect( - root.extraCapabilities?.flatMap(capability => capability.systemToolSets ?? []).map(toolSet => toolSet.name), - ).toEqual(['x-tfg-tools']); - expect(nested.extraCapabilities).toBeUndefined(); - }); - - it('mounts nothing when the header listed no tools', async () => { - const resolver = resolverWith([]); - const resolved = await resolver.resolveAgentDefinition({ - spec, - signal: new AbortController().signal, - tracing: resolver.createTracing(), - }); - - expect(resolved.extraCapabilities).toBeUndefined(); - }); -}); diff --git a/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts index 0ee3bd59e6..3f79f353ad 100644 --- a/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts @@ -1,7 +1,12 @@ import { HTTPException } from 'hono/http-exception'; import { ZodError } from 'zod'; -import { assertTfgToolsHeaderAllowed, parseTfgTools } from '../../../src/apis/turns'; -import { tfgToolsCapability, X_TFG_TOOLS } from '../../../src/truefoundry/inlineTools'; +import { + assertTfgToolsHeaderAllowed, + capabilitiesStoreFromHeader, + parseTfgTools, + tfgToolsCapability, + X_TFG_TOOLS, +} from '../../../src/truefoundry/inlineTools'; const askUserSecret = { name: 'ask_user_secret', @@ -132,3 +137,33 @@ describe('tfgToolsCapability', () => { }); }); }); + +describe('capabilitiesStoreFromHeader', () => { + it('mounts nothing when the header is absent', () => { + const store = capabilitiesStoreFromHeader({ header: undefined, isTrueFoundryMode: false }); + expect(store.resolveCapabilities()).toEqual([]); + }); + + it('mounts nothing for an empty list', () => { + const store = capabilitiesStoreFromHeader({ header: '[]', isTrueFoundryMode: true }); + expect(store.resolveCapabilities()).toEqual([]); + }); + + it('rejects the header outside TrueFoundry mode before parsing', () => { + expect(() => capabilitiesStoreFromHeader({ header: 'not-json', isTrueFoundryMode: false })).toThrow( + expect.objectContaining({ message: `${X_TFG_TOOLS} requires TrueFoundry mode` }), + ); + }); + + it('returns a fresh tool set on every resolve', () => { + const store = capabilitiesStoreFromHeader({ + header: JSON.stringify([askUserSecret]), + isTrueFoundryMode: true, + }); + const first = store.resolveCapabilities(); + const second = store.resolveCapabilities(); + expect(first[0]?.systemToolSets?.[0]?.name).toBe(X_TFG_TOOLS); + expect(second[0]?.systemToolSets?.[0]?.name).toBe(X_TFG_TOOLS); + expect(first[0]?.systemToolSets?.[0]).not.toBe(second[0]?.systemToolSets?.[0]); + }); +});