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..05beb34a2b --- /dev/null +++ b/.changeset/x-tfg-tools-ask-user-secret.md @@ -0,0 +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. 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/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/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-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'); + }); +}); 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 f0c64699da..71e9c5726f 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -35,7 +35,12 @@ import type { Context } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { streamSSE } from 'hono/streaming'; import type { Logger } from 'winston'; -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 from '../config'; @@ -71,6 +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 type { ICapabilitiesStore } from '../truefoundry/inlineTools'; import { newId } from '../utils/id'; import { resolveWebSearchProvider } from '../websearch/providers'; import { canReadAgentBoundResource, canReadSession } from './agentAccess'; @@ -125,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; @@ -151,6 +159,7 @@ export type BeginTurnExecutionDeps = Pick< sandboxProviderStore: ISandboxProviderStore; webSearchProviderStore: IWebSearchProviderStore; skillStore: Pick; + capabilitiesStore: ICapabilitiesStore; }; interface BeginTurnExecutionParams { @@ -180,6 +189,7 @@ function createTurnResolver(deps: { userRef: string; session: SessionHandle; turnMetadata: TurnMetadata; + capabilitiesStore: ICapabilitiesStore; }): TurnResourceResolver { const { mcpServerStore, @@ -194,6 +204,7 @@ function createTurnResolver(deps: { userRef, session, turnMetadata, + capabilitiesStore, } = deps; const tenant_id = session.tenant_id; const sessionId = session.session_id; @@ -294,6 +305,7 @@ function createTurnResolver(deps: { }, webSearchProvider, logger, + rootCapabilities: () => capabilitiesStore.resolveCapabilities(), }); } @@ -492,6 +504,7 @@ export async function beginTurnExecution( ...(sessionAgent.type === 'reference' ? { agent: { id: sessionAgent.id, name: sessionAgent.name } } : {}), ...(requestHeaders === undefined ? {} : { requestHeaders }), }, + capabilitiesStore: deps.capabilitiesStore, }); // First turn only: derive the title from the first user message. The store @@ -870,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/inlineTools.ts b/packages/trueforge/src/truefoundry/inlineTools.ts new file mode 100644 index 0000000000..0f5845e7f2 --- /dev/null +++ b/packages/trueforge/src/truefoundry/inlineTools.ts @@ -0,0 +1,142 @@ +import { ToolSchema as McpToolSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + ClientSideTool, + toolResultResponse, + 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'; + override readonly tracingEnabled = false; + readonly #tools: ToolDefinition[]; + + constructor(tools: readonly InlineTool[]) { + super(); + this.#tools = tools.map(tool => ({ + ...tool, + execute: () => + Promise.resolve( + toolResultResponse({ + text: 'This tool requires client-side execution and cannot be run server-side.', + isError: true, + }), + ), + })); + } + + protected getTools(): ToolDefinition[] { + return this.#tools; + } +} 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/tfgTools.test.ts b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts new file mode 100644 index 0000000000..3f79f353ad --- /dev/null +++ b/packages/trueforge/tests/unit/truefoundry/tfgTools.test.ts @@ -0,0 +1,169 @@ +import { HTTPException } from 'hono/http-exception'; +import { ZodError } from 'zod'; +import { + assertTfgToolsHeaderAllowed, + capabilitiesStoreFromHeader, + parseTfgTools, + tfgToolsCapability, + X_TFG_TOOLS, +} from '../../../src/truefoundry/inlineTools'; + +const askUserSecret = { + name: 'ask_user_secret', + description: 'Collect a secret and return a tfy-ephemeral-secret:// placeholder for apply_manifest.', + inputSchema: { + type: 'object', + properties: { + question: { type: 'string' }, + fields: { type: 'array' }, + }, + required: ['question', 'fields'], + additionalProperties: false, + }, + outputSchema: { + type: 'object', + properties: { + secret: { type: 'string' }, + }, + }, + annotations: { readOnlyHint: true }, +}; + +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 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]); + }); + + 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 definitions`], + ['a string entry', '["ask_user_secret"]', `${X_TFG_TOOLS}[0]`], + [ + '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', + 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: expect.stringContaining(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) })); + }); + + 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); + }); +}); + +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']); + expect(listed.result.tools[0]?.description).toContain('tfy-ephemeral-secret://'); + expect(listed.result.tools[0]?.inputSchema).toMatchObject({ + type: 'object', + 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)) { + throw new Error('expected a client-side tool'); + } + expect(response.clientSideToolRequired.tool_info).toEqual({ + type: 'truefoundry-system', + original_tool_name: 'ask_user_secret', + mcp_server_id: 'x-tfg-tools', + mcp_server_name: 'x-tfg-tools', + is_client_side: true, + }); + }); +}); + +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]); + }); +});