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;