diff --git a/.changeset/analytics-events-trueforge-prefix.md b/.changeset/analytics-events-trueforge-prefix.md
new file mode 100644
index 0000000000..8c7a909942
--- /dev/null
+++ b/.changeset/analytics-events-trueforge-prefix.md
@@ -0,0 +1,5 @@
+---
+'@truefoundry/trueforge-ui': patch
+---
+
+Prefix public `AnalyticsEvents` names with `trueforge.` so host sinks (e.g. PostHog) can namespace product events.
diff --git a/.changeset/tfy-fine-grained-tool-streaming-header.md b/.changeset/tfy-fine-grained-tool-streaming-header.md
new file mode 100644
index 0000000000..9af6dac416
--- /dev/null
+++ b/.changeset/tfy-fine-grained-tool-streaming-header.md
@@ -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
diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json
index 9c5a1131be..8b7a499790 100644
--- a/.github/fern/openapi/openapi.json
+++ b/.github/fern/openapi/openapi.json
@@ -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": {
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f57f93bf6e..32ac3cf9ab 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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 }}
diff --git a/Dockerfile b/Dockerfile
index 10f7ae2d60..250e5f2055 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/docs/openapi.json b/docs/openapi.json
index 9c5a1131be..8b7a499790 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -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": {
diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx
index fc8f22a517..65e7c7b708 100644
--- a/packages/frontend/src/App.tsx
+++ b/packages/frontend/src/App.tsx
@@ -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 ``. */
const authAwareFetch = createAuthAwareFetch();
// UI + API share the public prefix from `window.__TRUEFORGE_BASE_PATH__`.
@@ -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 (
diff --git a/packages/trueforge-core/src/core/llm/VercelAILLM.ts b/packages/trueforge-core/src/core/llm/VercelAILLM.ts
index ec126ba883..71cfdc8d12 100644
--- a/packages/trueforge-core/src/core/llm/VercelAILLM.ts
+++ b/packages/trueforge-core/src/core/llm/VercelAILLM.ts
@@ -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,
@@ -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);
}
@@ -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' };
@@ -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 } : {}),
diff --git a/packages/trueforge-core/src/core/mcp/executeToolCalls.ts b/packages/trueforge-core/src/core/mcp/executeToolCalls.ts
index f8a8e77ab2..80625f11ce 100644
--- a/packages/trueforge-core/src/core/mcp/executeToolCalls.ts
+++ b/packages/trueforge-core/src/core/mcp/executeToolCalls.ts
@@ -37,6 +37,36 @@ export interface ExecuteToolCallsResult {
events: RegisteredPassthroughEvent[];
}
+function isJsonObject(value: unknown): value is Record {
+ 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 } | { 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,
@@ -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 = JSON.parse(toolCall.function.arguments || '{}') as Record;
const response = await toolInfo.toolSet.callTool(
{
name: toolInfo.originalToolName,
- arguments: args,
+ arguments: parsedArgs.args,
},
approvalDecisions.get(toolCall.id),
);
diff --git a/packages/trueforge-core/tests/core/llm/VercelAILLM.output.test.ts b/packages/trueforge-core/tests/core/llm/VercelAILLM.output.test.ts
index 6c9a784f3d..955d2aca11 100644
--- a/packages/trueforge-core/tests/core/llm/VercelAILLM.output.test.ts
+++ b/packages/trueforge-core/tests/core/llm/VercelAILLM.output.test.ts
@@ -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 },
+ });
}
});
@@ -263,6 +265,7 @@ describe('buildProviderOptions', () => {
});
expect(opts['anthropic']).toEqual({
cacheControl: { type: 'ephemeral' },
+ toolStreaming: true,
thinking: { type: 'adaptive', display: 'summarized' },
effort: 'max',
});
@@ -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', () => {
@@ -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', () => {
@@ -298,6 +303,7 @@ describe('buildProviderOptions', () => {
expect(opts['anthropic']).toMatchObject({
cacheControl: { type: 'ephemeral', ttl: '1h' },
disableParallelToolUse: true,
+ toolStreaming: true,
});
});
});
diff --git a/packages/trueforge-core/tests/core/mcp/executeToolCalls.test.ts b/packages/trueforge-core/tests/core/mcp/executeToolCalls.test.ts
new file mode 100644
index 0000000000..251387a6d9
--- /dev/null
+++ b/packages/trueforge-core/tests/core/mcp/executeToolCalls.test.ts
@@ -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([['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');
+ });
+});
diff --git a/packages/trueforge-ui/src/analytics/events.ts b/packages/trueforge-ui/src/analytics/events.ts
index f3ad0e6966..4ca0eda6d0 100644
--- a/packages/trueforge-ui/src/analytics/events.ts
+++ b/packages/trueforge-ui/src/analytics/events.ts
@@ -3,74 +3,74 @@
*/
export const AnalyticsEvents = {
Message: {
- SENT: 'agent.message_sent',
- CANCELLED: 'agent.message_cancelled',
- COPIED: 'agent.message_copied',
- EDIT_STARTED: 'agent.message_edit_started',
- RETRIED: 'agent.message_retried',
+ SENT: 'trueforge.agent.message_sent',
+ CANCELLED: 'trueforge.agent.message_cancelled',
+ COPIED: 'trueforge.agent.message_copied',
+ EDIT_STARTED: 'trueforge.agent.message_edit_started',
+ RETRIED: 'trueforge.agent.message_retried',
},
Attachment: {
- PICKED: 'agent.attachment_picked',
+ PICKED: 'trueforge.agent.attachment_picked',
},
Session: {
- NEW: 'agent.session_new',
- SELECTED: 'agent.session_selected',
- DELETED: 'agent.session_deleted',
- RENAMED: 'agent.session_renamed',
- SHARE_OPENED: 'agent.session_share_opened',
- SHARE_LINK_COPIED: 'agent.session_share_link_copied',
+ NEW: 'trueforge.agent.session_new',
+ SELECTED: 'trueforge.agent.session_selected',
+ DELETED: 'trueforge.agent.session_deleted',
+ RENAMED: 'trueforge.agent.session_renamed',
+ SHARE_OPENED: 'trueforge.agent.session_share_opened',
+ SHARE_LINK_COPIED: 'trueforge.agent.session_share_link_copied',
},
Tool: {
- APPROVAL_RESOLVED: 'agent.tool_approval_resolved',
+ APPROVAL_RESOLVED: 'trueforge.agent.tool_approval_resolved',
},
AskUser: {
- SUBMITTED: 'agent.ask_user_submitted',
+ SUBMITTED: 'trueforge.agent.ask_user_submitted',
},
Config: {
- OPENED: 'agent.config_opened',
+ OPENED: 'trueforge.agent.config_opened',
},
Settings: {
- OPENED: 'agent.settings_opened',
- CLOSED: 'settings.closed',
- SECTION_CHANGED: 'settings.section_changed',
- MODEL_PROVIDER_SAVED: 'settings.model_provider_saved',
- MODEL_PROVIDER_DELETED: 'settings.model_provider_deleted',
- CONNECTOR_SAVED: 'settings.connector_saved',
- CONNECTOR_DISCONNECTED: 'settings.connector_disconnected',
- CONNECTOR_DELETED: 'settings.connector_deleted',
- SKILL_IMPORTED: 'settings.skill_imported',
- SKILL_DELETED: 'settings.skill_deleted',
- SANDBOX_PROVIDER_SAVED: 'settings.sandbox_provider_saved',
- SANDBOX_PROVIDER_DELETED: 'settings.sandbox_provider_deleted',
- WEB_SEARCH_PROVIDER_SAVED: 'settings.web_search_provider_saved',
+ OPENED: 'trueforge.agent.settings_opened',
+ CLOSED: 'trueforge.settings.closed',
+ SECTION_CHANGED: 'trueforge.settings.section_changed',
+ MODEL_PROVIDER_SAVED: 'trueforge.settings.model_provider_saved',
+ MODEL_PROVIDER_DELETED: 'trueforge.settings.model_provider_deleted',
+ CONNECTOR_SAVED: 'trueforge.settings.connector_saved',
+ CONNECTOR_DISCONNECTED: 'trueforge.settings.connector_disconnected',
+ CONNECTOR_DELETED: 'trueforge.settings.connector_deleted',
+ SKILL_IMPORTED: 'trueforge.settings.skill_imported',
+ SKILL_DELETED: 'trueforge.settings.skill_deleted',
+ SANDBOX_PROVIDER_SAVED: 'trueforge.settings.sandbox_provider_saved',
+ SANDBOX_PROVIDER_DELETED: 'trueforge.settings.sandbox_provider_deleted',
+ WEB_SEARCH_PROVIDER_SAVED: 'trueforge.settings.web_search_provider_saved',
},
Library: {
- OPENED: 'library.opened',
- CLOSED: 'library.closed',
- AGENT_TRIED: 'library.agent_tried',
- AGENT_EDITED: 'library.agent_edited',
- AGENT_DETAILS_OPENED: 'library.agent_details_opened',
- AGENT_SCHEDULES_OPENED: 'library.agent_schedules_opened',
+ OPENED: 'trueforge.library.opened',
+ CLOSED: 'trueforge.library.closed',
+ AGENT_TRIED: 'trueforge.library.agent_tried',
+ AGENT_EDITED: 'trueforge.library.agent_edited',
+ AGENT_DETAILS_OPENED: 'trueforge.library.agent_details_opened',
+ AGENT_SCHEDULES_OPENED: 'trueforge.library.agent_schedules_opened',
},
AgentDetails: {
- OPENED: 'agent_details.opened',
- TAB_CHANGED: 'agent_details.tab_changed',
+ OPENED: 'trueforge.agent_details.opened',
+ TAB_CHANGED: 'trueforge.agent_details.tab_changed',
},
SessionsBrowser: {
- OPENED: 'sessions_browser.opened',
+ OPENED: 'trueforge.sessions_browser.opened',
},
Schedule: {
- PAGE_OPENED: 'schedule.page_opened',
- CREATED: 'schedule.created',
- EDITED: 'schedule.edited',
- DELETED: 'schedule.deleted',
- TOGGLED: 'schedule.toggled',
- RUN_NOW: 'schedule.run_now',
+ PAGE_OPENED: 'trueforge.schedule.page_opened',
+ CREATED: 'trueforge.schedule.created',
+ EDITED: 'trueforge.schedule.edited',
+ DELETED: 'trueforge.schedule.deleted',
+ TOGGLED: 'trueforge.schedule.toggled',
+ RUN_NOW: 'trueforge.schedule.run_now',
},
Environment: {
- PAGE_OPENED: 'environment.page_opened',
- CREATED: 'environment.created',
- EDITED: 'environment.edited',
- DELETED: 'environment.deleted',
+ PAGE_OPENED: 'trueforge.environment.page_opened',
+ CREATED: 'trueforge.environment.created',
+ EDITED: 'trueforge.environment.edited',
+ DELETED: 'trueforge.environment.deleted',
},
} as const;