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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/analytics-events-trueforge-prefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge-ui': patch
---

Prefix public `AnalyticsEvents` names with `trueforge.` so host sinks (e.g. PostHog) can namespace product events.
5 changes: 5 additions & 0 deletions .changeset/tfy-fine-grained-tool-streaming-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge-core': patch
---

Enable fine-grained tool streaming via TrueFoundry `anthropic-beta` header and Anthropic `toolStreaming`, and report invalid tool-arg JSON as a distinct parse error
2 changes: 1 addition & 1 deletion .github/fern/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6221,7 +6221,7 @@
"info": {
"description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.",
"title": "TrueForge API",
"version": "0.179.0"
"version": "0.179.2"
},
"openapi": "3.1.0",
"paths": {
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,8 @@ jobs:
image_build_args: |
BUILD_BASE_IMAGE=${{ vars.TRUEFORGE_NODE_BUILD_BASE_IMAGE || '526077812922.dkr.ecr.us-east-1.amazonaws.com/tfy-internal/node:24-dev' }}
RUNTIME_BASE_IMAGE=${{ vars.TRUEFORGE_NODE_RUNTIME_BASE_IMAGE || '526077812922.dkr.ecr.us-east-1.amazonaws.com/tfy-internal/node:24' }}
VITE_POSTHOG_HOST=${{ secrets.TRUEFORGE_POSTHOG_HOST }}
VITE_POSTHOG_API_KEY=${{ secrets.TRUEFORGE_POSTHOG_API_KEY }}
secrets:
artifactory_username: ${{ secrets.TRUEFOUNDRY_ARTIFACTORY_PRIVATE_USERNAME }}
artifactory_password: ${{ secrets.TRUEFOUNDRY_ARTIFACTORY_PRIVATE_PASSWORD }}
Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ RUN pnpm --filter @truefoundry/trueforge-core build && pnpm --filter @truefoundr
# tsc needs, so this stage never compiles SDK JavaScript.
# ---------------------------------------------------------------------------
FROM workspace AS frontend-builder
ARG VITE_POSTHOG_HOST=https://us.i.posthog.com
ARG VITE_POSTHOG_API_KEY=
ENV VITE_POSTHOG_HOST=$VITE_POSTHOG_HOST \
VITE_POSTHOG_API_KEY=$VITE_POSTHOG_API_KEY
RUN pnpm install --frozen-lockfile --offline --filter frontend...
COPY packages/trueforge-sdk packages/trueforge-sdk
COPY packages/assistant-ui-runtime packages/assistant-ui-runtime
Expand Down
2 changes: 1 addition & 1 deletion docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6221,7 +6221,7 @@
"info": {
"description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.",
"title": "TrueForge API",
"version": "0.179.0"
"version": "0.179.2"
},
"openapi": "3.1.0",
"paths": {
Expand Down
23 changes: 15 additions & 8 deletions packages/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,6 @@ function initAnalytics(): void {

initAnalytics();

const hostAnalytics: AnalyticsConfig | undefined = POSTHOG_API_KEY
? {
track: (eventName, data) => {
posthog.capture(eventName, data);
},
}
: undefined;

/** Shared cookie/OIDC fetch for boot helpers and `<TrueForgeUI server />`. */
const authAwareFetch = createAuthAwareFetch();
// UI + API share the public prefix from `window.__TRUEFORGE_BASE_PATH__`.
Expand Down Expand Up @@ -183,6 +175,21 @@ export function App() {
[],
);

const hostAnalytics = useMemo((): AnalyticsConfig | undefined => {
if (!POSTHOG_API_KEY || session.status !== 'authenticated') {
return undefined;
}
const { tenantId } = session;
return {
track: (eventName, data) => {
posthog.capture(eventName, {
tenant: tenantId,
...data,
});
},
};
}, [session]);

const authErrorReason = shouldShowAuthErrorScreen({ authError, session });
if (authErrorReason != null) {
return (
Expand Down
12 changes: 11 additions & 1 deletion packages/trueforge-core/src/core/llm/VercelAILLM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ function compatibleModel(config: VercelAIProviderConfig): LanguageModel {
if (baseUrl === undefined) {
throw new Error(`Provider "${provider.type}" requires a baseUrl`);
}
// More straightforward than mutating tool definitions for fine-grained tool streaming.
// TODO: fine-grained-tool-streaming-2025-05-14 is considered deprecated.
const outboundHeaders =
provider.type === 'truefoundry'
? { ...headers, 'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14' }
: headers;
const client = createOpenAICompatible({
name: provider.type,
baseURL: baseUrl,
Expand All @@ -141,7 +147,7 @@ function compatibleModel(config: VercelAIProviderConfig): LanguageModel {
supportsStructuredOutputs: true,
// These endpoints omit token counts from streamed responses unless asked.
includeUsage: true,
...(Object.keys(headers).length > 0 ? { headers } : {}),
...(Object.keys(outboundHeaders).length > 0 ? { headers: outboundHeaders } : {}),
});
return client(model.id);
}
Expand Down Expand Up @@ -390,6 +396,9 @@ function openaiProviderOptions({
*
* Prompt caching defaults on: Anthropic bills a write once, then cheaper reads on stable prefixes
* (system / tools / early messages). Callers can override via `cache_control` in the request body.
*
* `toolStreaming` is pinned on so every function tool gets `eager_input_streaming` on the Anthropic
* wire (call-level default).
*/
function anthropicProviderOptions(rawBody: unknown): JSONObject | undefined {
const cacheControl = readBodyField({ rawBody, key: 'cache_control' }) ?? { type: 'ephemeral' };
Expand All @@ -398,6 +407,7 @@ function anthropicProviderOptions(rawBody: unknown): JSONObject | undefined {
const effort = readBodyField({ rawBody, key: 'effort' });
const opts: JSONObject = {
cacheControl,
toolStreaming: true,
...(disableParallelToolUse !== undefined ? { disableParallelToolUse } : {}),
...(thinking !== undefined ? { thinking } : {}),
...(effort !== undefined ? { effort } : {}),
Expand Down
50 changes: 48 additions & 2 deletions packages/trueforge-core/src/core/mcp/executeToolCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,36 @@ export interface ExecuteToolCallsResult {
events: RegisteredPassthroughEvent[];
}

function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Parse tool-call `function.arguments`. Fine-grained streaming can leave incomplete JSON;
* failures are reported as tool errors so the model can retry rather than treating them as
* generic execution failures.
*/
function parseToolCallArguments(
rawArguments: string,
): { ok: true; args: Record<string, unknown> } | { ok: false; parseError: string } {
let parsed: unknown;
try {
parsed = JSON.parse(rawArguments || '{}');
} catch (error) {
return {
ok: false,
parseError: error instanceof Error ? error.message : 'JSON parse failed',
};
}
if (!isJsonObject(parsed)) {
return {
ok: false,
parseError: 'Tool call arguments must be a JSON object',
};
}
return { ok: true, args: parsed };
}

export async function executeToolCalls({
assistantMessage,
toolMapping,
Expand Down Expand Up @@ -82,12 +112,28 @@ export async function executeToolCalls({
};
}

const parsedArgs = parseToolCallArguments(toolCall.function.arguments);
if (!parsedArgs.ok) {
return {
toolCall,
toolInfo,
response: toolResultResponse({
text: JSON.stringify({
error: 'Failed to parse tool call arguments as JSON',
parse_error: parsedArgs.parseError,
}),
isError: true,
}),
failure: true,
completedAt: new Date().toISOString(),
};
}

try {
const args: Record<string, unknown> = JSON.parse(toolCall.function.arguments || '{}') as Record<string, unknown>;
const response = await toolInfo.toolSet.callTool(
{
name: toolInfo.originalToolName,
arguments: args,
arguments: parsedArgs.args,
},
approvalDecisions.get(toolCall.id),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,9 @@ describe('buildProviderOptions', () => {
structuredOutputSpec: textSpec,
rawBody: {},
});
expect(opts).toEqual({ anthropic: { cacheControl: { type: 'ephemeral' } } });
expect(opts).toEqual({
anthropic: { cacheControl: { type: 'ephemeral' }, toolStreaming: true },
});
}
});

Expand All @@ -263,6 +265,7 @@ describe('buildProviderOptions', () => {
});
expect(opts['anthropic']).toEqual({
cacheControl: { type: 'ephemeral' },
toolStreaming: true,
thinking: { type: 'adaptive', display: 'summarized' },
effort: 'max',
});
Expand All @@ -275,7 +278,9 @@ describe('buildProviderOptions', () => {
structuredOutputSpec: schemaSpecStrict,
rawBody: {},
});
expect(opts).toEqual({ anthropic: { cacheControl: { type: 'ephemeral' } } });
expect(opts).toEqual({
anthropic: { cacheControl: { type: 'ephemeral' }, toolStreaming: true },
});
});

it('defaults cacheControl to ephemeral when cache_control is omitted', () => {
Expand All @@ -285,7 +290,7 @@ describe('buildProviderOptions', () => {
structuredOutputSpec: textSpec,
rawBody: {},
});
expect(opts['anthropic']).toEqual({ cacheControl: { type: 'ephemeral' } });
expect(opts['anthropic']).toEqual({ cacheControl: { type: 'ephemeral' }, toolStreaming: true });
});

it('forwards cache_control and disable_parallel_tool_use from rawBody', () => {
Expand All @@ -298,6 +303,7 @@ describe('buildProviderOptions', () => {
expect(opts['anthropic']).toMatchObject({
cacheControl: { type: 'ephemeral', ttl: '1h' },
disableParallelToolUse: true,
toolStreaming: true,
});
});
});
Expand Down
38 changes: 38 additions & 0 deletions packages/trueforge-core/tests/core/mcp/executeToolCalls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { MappedMCPTool } from '../../../src/core/mcp/convertMCPServers';
import { executeToolCalls } from '../../../src/core/mcp/executeToolCalls';
import { makeMockIMCPServer } from '../harnessMocks';

describe('executeToolCalls', () => {
it('reports invalid tool-arg JSON as a parse failure without calling the tool', async () => {
const toolSet = makeMockIMCPServer({ name: 'srv', preload: true });
const toolMapping = new Map<string, MappedMCPTool>([['search', { toolSet, originalToolName: 'search' }]]);

const { toolCallResults } = await executeToolCalls({
assistantMessage: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_1',
type: 'function',
function: { name: 'search', arguments: '{"q":' },
tool_info: {
type: 'mcp',
mcp_server_id: 'srv',
mcp_server_name: 'srv',
original_tool_name: 'search',
},
},
],
},
toolMapping,
threadId: 'thread-1',
approvalDecisions: new Map(),
});

expect(toolSet.callTool).not.toHaveBeenCalled();
expect(toolCallResults).toHaveLength(1);
expect(toolCallResults[0]?.failure).toBe(true);
expect(toolCallResults[0]?.message.content).toContain('Failed to parse tool call arguments as JSON');
});
});
Loading
Loading