Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/x-tfg-tools-ask-user-secret.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -33,6 +34,16 @@ function specWantsSandbox(spec: AgentSpec): boolean {
return spec.config.sandbox.enabled;
}

function rootCapabilitiesOf(
rootCapabilities: (() => readonly AgentCapability[]) | undefined,
): { extraCapabilities: AgentCapability[] } | Record<string, never> {
const capabilities = rootCapabilities?.() ?? [];
if (capabilities.length === 0) {
return {};
}
return { extraCapabilities: [...capabilities] };
}

function toSelectors(entry: {
enable_tools: string[];
disable_tools: string[];
Expand Down Expand Up @@ -88,6 +99,12 @@ export class TurnResourceResolver<
agent?: ((agentId: string) => Promise<AgentSpec>) | 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;
},
Expand Down Expand Up @@ -237,6 +254,7 @@ export class TurnResourceResolver<
iterationLimit: spec.config.iteration_limit,
toolSets,
},
...(agentInfo === undefined ? rootCapabilitiesOf(this.deps.rootCapabilities) : {}),
};
}

Expand Down
11 changes: 7 additions & 4 deletions packages/trueforge-core/src/core/mcp/LocalToolMCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
62 changes: 61 additions & 1 deletion packages/trueforge-core/tests/core/mcp/LocalToolMCP.test.ts
Original file line number Diff line number Diff line change
@@ -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)', () => {
Expand Down Expand Up @@ -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');
});
});
1 change: 1 addition & 0 deletions packages/trueforge/scripts/write-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const app = createServerApp({
}),
resolveSkillStore: () => skillStore,
resolveTurnSkillsStore: () => skillStore,
resolveCapabilitiesStore: () => ({ resolveCapabilities: () => [] }),
resolveSandboxProviderStore: () => sandboxProviderStore,
resolveWebSearchProviderStore: () => webSearchProviderStore,
resolveAgentStore: () => agentStore,
Expand Down
6 changes: 5 additions & 1 deletion packages/trueforge/src/apis/schedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';

Expand All @@ -84,6 +85,8 @@ export interface ScheduleTurnExecutionDeps<TTransaction> {
agentStore: IAgentStore<TTransaction>;
/** Call with `{}` — schedules have no caller `x-tfg-skills`. */
resolveTurnSkillsStore: (input: ResolveTurnSkillsStoreInput) => Pick<ISkillStore, 'resolveTurnSkills'>;
/** Call with `{}` — schedules have no caller `x-tfg-tools`. */
resolveCapabilitiesStore: (input: ResolveCapabilitiesStoreInput) => ICapabilitiesStore;
}

export interface SchedulesRouterDeps<TTransaction> extends ScheduleTurnExecutionDeps<TTransaction> {
Expand Down Expand Up @@ -127,6 +130,7 @@ export async function startScheduleRunOnRequest<TTransaction>(params: {
sandboxEnvironmentStore: deps.sandboxEnvironmentStore,
webSearchProviderStore: deps.resolveWebSearchProviderStore(c),
skillStore: deps.resolveTurnSkillsStore({}),
capabilitiesStore: deps.resolveCapabilitiesStore({}),
logger: deps.logger,
},
});
Expand Down
17 changes: 16 additions & 1 deletion packages/trueforge/src/apis/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -125,6 +131,8 @@ export interface TurnsRouterDeps {
resolveModelProviderStore: (input: ResolveModelProviderStoreInput) => IModelProviderStore;
resolveMcpServerStore: (input: ResolveMcpServerStoreInput) => IMcpServerWithAuthStore;
resolveTurnSkillsStore: (input: ResolveTurnSkillsStoreInput) => Pick<ISkillStore, 'resolveTurnSkills'>;
/** `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<TurnStreamingEvent>;
Expand All @@ -151,6 +159,7 @@ export type BeginTurnExecutionDeps = Pick<
sandboxProviderStore: ISandboxProviderStore;
webSearchProviderStore: IWebSearchProviderStore;
skillStore: Pick<ISkillStore, 'resolveTurnSkills'>;
capabilitiesStore: ICapabilitiesStore;
};

interface BeginTurnExecutionParams {
Expand Down Expand Up @@ -180,6 +189,7 @@ function createTurnResolver(deps: {
userRef: string;
session: SessionHandle;
turnMetadata: TurnMetadata;
capabilitiesStore: ICapabilitiesStore;
}): TurnResourceResolver {
const {
mcpServerStore,
Expand All @@ -194,6 +204,7 @@ function createTurnResolver(deps: {
userRef,
session,
turnMetadata,
capabilitiesStore,
} = deps;
const tenant_id = session.tenant_id;
const sessionId = session.session_id;
Expand Down Expand Up @@ -294,6 +305,7 @@ function createTurnResolver(deps: {
},
webSearchProvider,
logger,
rootCapabilities: () => capabilitiesStore.resolveCapabilities(),
});
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/trueforge/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -300,6 +306,8 @@ export interface ServerDeps<TTransaction> {
* schedules call with `{}` (registry/DB only).
*/
resolveTurnSkillsStore: (input: ResolveTurnSkillsStoreInput) => Pick<ISkillStore<TTransaction>, 'resolveTurnSkills'>;
/** `x-tfg-tools` for this turn. Schedules call with `{}` and mount nothing. */
resolveCapabilitiesStore: (input: ResolveCapabilitiesStoreInput) => ICapabilitiesStore;
withTransaction: WithTransaction<TTransaction>;
tokenStore: IOAuthTokenStore<TTransaction>;
scheduleStore: IScheduleStore<TTransaction>;
Expand Down Expand Up @@ -355,6 +363,7 @@ export function createServerApp<TTransaction>(deps: ServerDeps<TTransaction>) {
resolveWebSearchProviderStore: deps.resolveWebSearchProviderStore,
activeTurns: deps.activeTurns,
resolveTurnSkillsStore: deps.resolveTurnSkillsStore,
resolveCapabilitiesStore: deps.resolveCapabilitiesStore,
};

const httpMetrics = deps.httpMetrics;
Expand Down Expand Up @@ -619,6 +628,7 @@ export function createServerApp<TTransaction>(deps: ServerDeps<TTransaction>) {
resolveModelProviderStore: deps.resolveModelProviderStore,
resolveMcpServerStore: deps.resolveMcpServerStore,
resolveTurnSkillsStore: deps.resolveTurnSkillsStore,
resolveCapabilitiesStore: deps.resolveCapabilitiesStore,
resolveAgentStore: deps.resolveAgentStore,
eventSubscriptions: deps.eventSubscriptions,
resolveSandboxProviderStore: deps.resolveSandboxProviderStore,
Expand Down
Loading
Loading