From 6cd9a2bfdb8ac16534feb88972f584ef738d9e40 Mon Sep 17 00:00:00 2001 From: Robin Braumann <50590409+bjorger@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:02:00 +0000 Subject: [PATCH] feat: Add LangSmith agent session debug export (#36309) Co-authored-by: Cursor --- .../agents/src/sdk/guardrails/redactor.ts | 28 +- packages/@n8n/api-types/src/agents/types.ts | 4 + ...t-session-langsmith-export.service.test.ts | 399 +++++++++++++++ .../agent-threads.controller.test.ts | 2 + .../agent-session-langsmith-export.service.ts | 471 ++++++++++++++++++ .../agents/agent-threads.controller.ts | 22 +- .../agent-execution-thread.repository.ts | 10 + .../frontend/@n8n/i18n/src/locales/en.json | 7 + .../agents/__tests__/AgentBuilderView.test.ts | 2 +- .../agents/__tests__/AgentChatPanel.test.ts | 8 + .../agents/__tests__/AgentHeaders.test.ts | 2 + .../agents/__tests__/AgentPreviewDock.test.ts | 8 + .../features/agents/agentSessions.store.ts | 7 + .../agents/components/AgentPreviewDock.vue | 34 ++ .../components/AgentSessionTimelineHeader.vue | 34 +- .../useAgentSessionLangSmithExport.ts | 62 +++ .../agents/composables/useAgentThreadsApi.ts | 14 + .../agents/views/AgentSessionTimelineView.vue | 13 +- 18 files changed, 1116 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts create mode 100644 packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentSessionLangSmithExport.ts diff --git a/packages/@n8n/agents/src/sdk/guardrails/redactor.ts b/packages/@n8n/agents/src/sdk/guardrails/redactor.ts index edf377b1c5e..6b46109b601 100644 --- a/packages/@n8n/agents/src/sdk/guardrails/redactor.ts +++ b/packages/@n8n/agents/src/sdk/guardrails/redactor.ts @@ -16,6 +16,8 @@ export interface RedactionOptions { * secrets), userinfo, fragment. Off by default so guardrail behavior is * unchanged; telemetry/trace scrubbing opts in. */ preserveUrlStructure?: boolean; + /** Replace values under secret-shaped object keys. */ + redactSensitiveKeys?: boolean; } export interface RedactionResult { @@ -148,6 +150,8 @@ export function findMatchRanges( } const MAX_DEEP_DEPTH = 8; +const SENSITIVE_KEY_PATTERN = + /(api[_-]?key|private[_-]?key|authorization|bearer|cookie|credentials?|password|secret|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|auth[_-]?token|(?:^|[._-])token$)/i; export interface DeepRedactionResult { value: unknown; @@ -165,17 +169,35 @@ export function redactDeep( opts: RedactionOptions = {}, depth = 0, ): DeepRedactionResult { + return redactDeepValue(value, opts, depth); +} + +function redactDeepValue( + value: unknown, + opts: RedactionOptions, + depth: number, + key?: string, +): DeepRedactionResult { + if (opts.redactSensitiveKeys && key && SENSITIVE_KEY_PATTERN.test(key)) { + return { value: opts.placeholder ?? DEFAULT_PLACEHOLDER, matches: [{ category: 'secret' }] }; + } + if (typeof value === 'string') { const { text, matches } = redactText(value, opts); return { value: text, matches }; } - if (depth >= MAX_DEEP_DEPTH) return { value, matches: [] }; + if (depth >= MAX_DEEP_DEPTH) { + if (opts.redactSensitiveKeys && value !== null && typeof value === 'object') { + return { value: opts.placeholder ?? DEFAULT_PLACEHOLDER, matches: [{ category: 'secret' }] }; + } + return { value, matches: [] }; + } if (Array.isArray(value)) { const matches: Array<{ category: RedactionCategory }> = []; const next = value.map((item) => { - const result = redactDeep(item, opts, depth + 1); + const result = redactDeepValue(item, opts, depth + 1, key); matches.push(...result.matches); return result.value; }); @@ -186,7 +208,7 @@ export function redactDeep( const matches: Array<{ category: RedactionCategory }> = []; const next: Record = {}; for (const [key, item] of Object.entries(value)) { - const result = redactDeep(item, opts, depth + 1); + const result = redactDeepValue(item, opts, depth + 1, key); matches.push(...result.matches); next[key] = result.value; } diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index 457af2c66a9..884a4c04832 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -253,6 +253,10 @@ export interface AgentChatMessagesResponse { openSuspensions: AgentBuilderOpenSuspension[]; } +export interface AgentSessionLangSmithExportResponse { + traceId: string; +} + /** * Internal integration type for the in-app chat channel. Injected per-run for * `/chat` executions — never persisted in an agent's `integrations` array. diff --git a/packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts new file mode 100644 index 00000000000..24319a4d6da --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts @@ -0,0 +1,399 @@ +import { mockLogger } from '@n8n/backend-test-utils'; +import type { CustomFetch, HttpTransport, OutboundHttp } from '@n8n/backend-network'; +import type { User } from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; + +import { N8N_VERSION } from '@/constants'; +import type { AiService } from '@/services/ai.service'; + +import { AgentSessionLangSmithExportService } from '../agent-session-langsmith-export.service'; +import type { AgentExecutionService, ThreadDetail } from '../agent-execution.service'; +import type { AgentExecution } from '../entities/agent-execution.entity'; +import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity'; +import type { AgentExecutionThreadRepository } from '../repositories/agent-execution-thread.repository'; + +const createRunMock = vi.hoisted(() => vi.fn()); + +vi.mock('langsmith', () => ({ + Client: class { + createRun = createRunMock; + }, +})); + +function makeThread(overrides: Partial = {}): AgentExecutionThread { + return { + id: 'parent-thread', + agentId: 'parent-agent', + agentName: 'Parent Agent', + projectId: 'project-1', + title: 'Debug session', + emoji: null, + parentThreadId: null, + parentAgentId: null, + taskId: null, + taskVersionId: null, + sessionNumber: 1, + totalPromptTokens: 12, + totalCompletionTokens: 8, + totalCost: 0.04, + totalDuration: 1000, + createdAt: new Date('2026-08-14T09:00:00.000Z'), + updatedAt: new Date('2026-08-14T09:00:01.000Z'), + ...overrides, + } as AgentExecutionThread; +} + +function makeExecution(overrides: Partial = {}): AgentExecution { + return { + id: 'parent-execution', + threadId: 'parent-thread', + status: 'success', + startedAt: new Date('2026-08-14T09:00:00.000Z'), + stoppedAt: new Date('2026-08-14T09:00:01.000Z'), + duration: 1000, + userMessage: 'Help robin@example.com', + attachments: [ + { id: 'attachment-1', fileName: 'notes.txt', mimeType: 'text/plain', sizeBytes: 42 }, + ], + model: 'anthropic/claude-sonnet', + promptTokens: 12, + completionTokens: 8, + totalTokens: 20, + cost: 0.04, + timeline: [ + { + type: 'text', + content: 'Reply to robin@example.com', + timestamp: Date.parse('2026-08-14T09:00:00.100Z'), + endTime: Date.parse('2026-08-14T09:00:00.200Z'), + }, + { + type: 'tool-call', + kind: 'tool', + name: 'search', + toolCallId: 'tool-success', + input: { + query: 'find docs', + apiKey: 'top-secret-key', + privateKey: 'first-value', + private_key: 'second-value', + 'private-key': 'third-value', + level1: { + level2: { + level3: { + level4: { + level5: { + level6: { + level7: { + level8: { password: 'deep-boundary-secret' }, + }, + }, + }, + }, + }, + }, + }, + }, + output: { result: 'found' }, + startTime: Date.parse('2026-08-14T09:00:00.300Z'), + endTime: Date.parse('2026-08-14T09:00:00.400Z'), + success: true, + }, + { + type: 'tool-call', + kind: 'node', + name: 'failing_tool', + toolCallId: 'tool-failure', + input: {}, + output: { message: 'Failed for robin@example.com' }, + startTime: Date.parse('2026-08-14T09:00:00.500Z'), + endTime: Date.parse('2026-08-14T09:00:00.600Z'), + success: false, + nodeType: 'n8n-nodes-base.httpRequest', + nodeTypeVersion: 4, + nodeDisplayName: 'HTTP Request', + nodeParameters: { authentication: 'none' }, + }, + { + type: 'tool-call', + kind: 'tool', + name: 'delegate_subagent', + toolCallId: 'inline-child', + input: { task: 'Inline research' }, + output: { answer: 'Inline answer' }, + startTime: Date.parse('2026-08-14T09:00:00.700Z'), + endTime: Date.parse('2026-08-14T09:00:00.800Z'), + success: true, + childTrace: { + text: 'Inline answer', + reasoningSegments: [], + steps: [{ toolCallId: 'inline-tool', toolName: 'lookup', running: false }], + }, + }, + { + type: 'tool-call', + kind: 'tool', + name: 'delegate_subagent', + toolCallId: 'configured-child', + input: { task: 'Configured research' }, + output: { threadId: 'child-thread', answer: 'Configured answer' }, + startTime: Date.parse('2026-08-14T09:00:00.900Z'), + endTime: Date.parse('2026-08-14T09:00:01.000Z'), + success: true, + }, + ], + error: null, + hitlStatus: null, + source: 'chat', + storedAt: 'db', + createdAt: new Date('2026-08-14T09:00:00.000Z'), + updatedAt: new Date('2026-08-14T09:00:01.000Z'), + ...overrides, + } as AgentExecution; +} + +function setup( + options: { + proxyEnabled?: boolean; + } = {}, +) { + const agentExecutionService = mock(); + const threadRepository = mock(); + const aiService = mock(); + aiService.isProxyEnabled.mockReturnValue(options.proxyEnabled ?? true); + aiService.getClient.mockResolvedValue({ + getApiProxyBaseUrl: () => 'https://ai-proxy.example/v1/api-proxy', + getBuilderApiProxyToken: vi.fn(), + } as never); + const transport = mock(); + transport.asCustomFetch.mockReturnValue(vi.fn() as unknown as CustomFetch); + const outboundHttp = mock(); + outboundHttp.transport.mockReturnValue(transport); + const service = new AgentSessionLangSmithExportService( + mockLogger(), + aiService, + outboundHttp, + agentExecutionService, + threadRepository, + ); + + return { service, agentExecutionService, threadRepository }; +} + +describe('AgentSessionLangSmithExportService', () => { + const input = { + projectId: 'project-1', + agentId: 'parent-agent', + threadId: 'parent-thread', + user: mock({ id: 'user-1' }), + }; + + beforeEach(() => { + createRunMock.mockReset(); + }); + + it('exports a complete redacted session tree with stable snapshot IDs', async () => { + const { service, agentExecutionService, threadRepository } = setup(); + const parentDetail: ThreadDetail = { + thread: makeThread(), + executions: [makeExecution()], + }; + const childThread = makeThread({ + id: 'child-thread', + agentId: 'child-agent', + agentName: 'Child Agent', + parentThreadId: 'parent-thread', + parentAgentId: 'parent-agent', + createdAt: new Date('2026-08-14T09:00:00.900Z'), + }); + const childDetail: ThreadDetail = { + thread: childThread, + executions: [ + makeExecution({ + id: 'child-execution', + threadId: 'child-thread', + userMessage: 'Child input', + attachments: null, + timeline: [ + { + type: 'text', + content: 'Child response', + timestamp: Date.parse('2026-08-14T09:00:00.950Z'), + endTime: Date.parse('2026-08-14T09:00:01.000Z'), + }, + ], + }), + ], + }; + agentExecutionService.getThreadDetail.mockImplementation(async (threadId) => + threadId === 'parent-thread' ? parentDetail : childDetail, + ); + threadRepository.findByParentThreadId.mockImplementation(async (threadId) => + threadId === 'parent-thread' ? [childThread] : [], + ); + + let releaseLastRun = () => {}; + let blockLastRun = true; + const lastRunBlocked = new Promise((resolve) => { + releaseLastRun = resolve; + }); + createRunMock.mockImplementation(async (run: { outputs?: Record }) => { + if (blockLastRun && run.outputs?.text === 'Child response') { + await lastRunBlocked; + } + }); + + let resolved = false; + const firstExport = service.exportSession(input).then((result) => { + resolved = true; + return result; + }); + await vi.waitFor(() => expect(createRunMock).toHaveBeenCalledTimes(10)); + expect(resolved).toBe(false); + releaseLastRun(); + const firstResult = await firstExport; + const firstRuns = createRunMock.mock.calls.map(([run]) => run); + + expect(firstRuns.map((run) => run.name)).toEqual([ + 'Agent session: Parent Agent', + 'Agent turn', + 'Agent response', + 'search', + 'failing_tool', + 'delegate_subagent', + 'delegate_subagent', + 'Agent session: Child Agent', + 'Agent turn', + 'Agent response', + ]); + expect(JSON.stringify(firstRuns)).not.toContain('robin@example.com'); + expect(JSON.stringify(firstRuns)).not.toContain('top-secret-key'); + expect(JSON.stringify(firstRuns)).not.toContain('first-value'); + expect(JSON.stringify(firstRuns)).not.toContain('second-value'); + expect(JSON.stringify(firstRuns)).not.toContain('third-value'); + expect(JSON.stringify(firstRuns)).not.toContain('deep-boundary-secret'); + expect(JSON.stringify(firstRuns)).toContain('[REDACTED]'); + expect(firstRuns[0].extra.metadata.n8nVersion).toBe(N8N_VERSION); + expect(firstRuns[1].inputs.attachments).toEqual([ + { id: 'attachment-1', fileName: 'notes.txt', mimeType: 'text/plain', sizeBytes: 42 }, + ]); + expect(firstRuns[4]).toMatchObject({ + error: 'Failed for [REDACTED]', + extra: { metadata: { success: false, nodeType: 'n8n-nodes-base.httpRequest' } }, + }); + expect(firstRuns[5].outputs.childTrace).toMatchObject({ + text: 'Inline answer', + steps: [{ toolName: 'lookup' }], + }); + expect(firstRuns[7].parent_run_id).toBe(firstRuns[6].id); + + blockLastRun = false; + createRunMock.mockClear(); + const secondResult = await service.exportSession(input); + const secondRuns = createRunMock.mock.calls.map(([run]) => run); + expect(secondResult.traceId).toBe(firstResult.traceId); + expect(secondRuns.map((run) => run.id)).toEqual(firstRuns.map((run) => run.id)); + + parentDetail.executions.push( + makeExecution({ + id: 'new-execution', + startedAt: new Date('2026-08-14T09:00:02.000Z'), + stoppedAt: new Date('2026-08-14T09:00:03.000Z'), + timeline: [], + }), + ); + const changedResult = await service.exportSession(input); + expect(changedResult.traceId).not.toBe(firstResult.traceId); + }); + + it('rejects before loading data when the AI proxy is disabled', async () => { + const { service, agentExecutionService } = setup({ proxyEnabled: false }); + + await expect(service.exportSession(input)).rejects.toThrow( + 'LangSmith debug export is not enabled', + ); + expect(agentExecutionService.getThreadDetail).not.toHaveBeenCalled(); + expect(createRunMock).not.toHaveBeenCalled(); + }); + + it('rejects inaccessible sessions without submitting a run', async () => { + const { service } = setup(); + + await expect(service.exportSession(input)).rejects.toThrow('Thread "parent-thread" not found'); + expect(createRunMock).not.toHaveBeenCalled(); + }); + + it('rejects when a child session is still running', async () => { + const child = setup(); + const childThread = makeThread({ + id: 'child-thread', + agentId: 'child-agent', + parentThreadId: 'parent-thread', + parentAgentId: 'parent-agent', + }); + child.agentExecutionService.getThreadDetail.mockImplementation(async (threadId) => ({ + thread: threadId === 'parent-thread' ? makeThread() : childThread, + executions: [makeExecution({ status: threadId === 'parent-thread' ? 'success' : 'running' })], + })); + child.threadRepository.findByParentThreadId.mockImplementation(async (threadId) => + threadId === 'parent-thread' ? [childThread] : [], + ); + await expect(child.service.exportSession(input)).rejects.toThrow('Session is still running'); + expect(createRunMock).not.toHaveBeenCalled(); + }); + + it('rejects when blob-stored execution data is unavailable', async () => { + const { service, agentExecutionService } = setup(); + agentExecutionService.getThreadDetail.mockResolvedValue({ + thread: makeThread(), + executions: [makeExecution({ storedAt: 'fs', timeline: null })], + }); + + await expect(service.exportSession(input)).rejects.toMatchObject({ + message: + "Session couldn't be exported because some execution data is unavailable. Try again.", + httpStatusCode: 503, + }); + expect(createRunMock).not.toHaveBeenCalled(); + }); + + it('rejects cyclic child links without submitting a run', async () => { + const { service, agentExecutionService, threadRepository } = setup(); + const rootThread = makeThread({ + parentThreadId: 'child-thread', + parentAgentId: 'child-agent', + }); + const childThread = makeThread({ + id: 'child-thread', + agentId: 'child-agent', + parentThreadId: 'parent-thread', + parentAgentId: 'parent-agent', + }); + agentExecutionService.getThreadDetail.mockImplementation(async (threadId) => ({ + thread: threadId === 'parent-thread' ? rootThread : childThread, + executions: [makeExecution({ threadId })], + })); + threadRepository.findByParentThreadId.mockImplementation(async (threadId) => + threadId === 'parent-thread' ? [childThread] : [rootThread], + ); + + await expect(service.exportSession(input)).rejects.toThrow( + 'Agent session contains a cyclic child link', + ); + expect(createRunMock).not.toHaveBeenCalled(); + }); + + it('returns a retryable error when LangSmith submission fails', async () => { + const { service, agentExecutionService, threadRepository } = setup(); + agentExecutionService.getThreadDetail.mockResolvedValue({ + thread: makeThread(), + executions: [makeExecution({ timeline: [] })], + }); + threadRepository.findByParentThreadId.mockResolvedValue([]); + createRunMock.mockRejectedValueOnce(new Error('upstream unavailable')); + + await expect(service.exportSession(input)).rejects.toThrow( + "Session couldn't be sent to LangSmith. Try again.", + ); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-threads.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-threads.controller.test.ts index 44ed26b4c26..b18582ec5ae 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-threads.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-threads.controller.test.ts @@ -19,6 +19,7 @@ describe('AgentThreadsController route access scopes', () => { it.each([ ['listThreads', 'get', '/:agentId/threads'], ['getThread', 'get', '/:agentId/threads/:threadId'], + ['exportThreadToLangSmith', 'post', '/:agentId/threads/:threadId/langsmith-export'], ['deleteThread', 'delete', '/:agentId/threads/:threadId'], ])('%s uses %s %s', (handlerName, method, path) => { expect(routes.get(handlerName)).toMatchObject({ method, path }); @@ -27,6 +28,7 @@ describe('AgentThreadsController route access scopes', () => { it.each([ ['listThreads', 'agent:read'], ['getThread', 'agent:read'], + ['exportThreadToLangSmith', 'agent:read'], ['deleteThread', 'agent:update'], ])('%s uses %s', (handlerName, scope) => { expect(routes.get(handlerName)?.accessScope?.scope).toBe(scope); diff --git a/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts b/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts new file mode 100644 index 00000000000..52b8a937109 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts @@ -0,0 +1,471 @@ +import { redactDeep, redactText, SUPPORTED_PII_CATEGORIES } from '@n8n/agents'; +import type { AgentSessionLangSmithExportResponse } from '@n8n/api-types'; +import { buildProxyHeaders } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { OutboundHttp } from '@n8n/backend-network'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { isRecord } from '@n8n/utils/is-record'; +import type { Client } from 'langsmith'; +import { nanoid } from 'nanoid'; +import { v5 as uuidv5 } from 'uuid'; + +import { N8N_VERSION } from '@/constants'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { AiService } from '@/services/ai.service'; +import { ProxyTokenManager } from '@/services/proxy-token-manager'; +import { createAiProxyFetch } from '@/utils/ai-proxy-fetch'; + +import type { AgentExecution } from './entities/agent-execution.entity'; +import type { AgentExecutionThread } from './entities/agent-execution-thread.entity'; +import type { TimelineEvent } from './execution-recorder'; +import { AgentExecutionService, type ThreadDetail } from './agent-execution.service'; +import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository'; + +const LANGSMITH_PROJECT = 'n8n-user-agents-debug'; +const EXPORT_NAMESPACE = uuidv5('n8n-agent-session-langsmith-export', uuidv5.URL); +const REDACTION_OPTIONS = { + secrets: true, + detect: SUPPORTED_PII_CATEGORIES, + preserveUrlStructure: true, + redactSensitiveKeys: true, +}; + +type LangSmithRun = Parameters[0]; +type DottedOrderConverter = ( + epoch: number, + runId: string, + executionOrder?: number, +) => { dottedOrder: string }; + +interface LoadedSession { + thread: AgentExecutionThread; + executions: AgentExecution[]; + children: LoadedSession[]; +} + +interface DraftRun { + path: string; + name: string; + runType: 'chain' | 'llm' | 'tool'; + startTime: number; + endTime?: number; + inputs: Record; + outputs?: Record; + error?: string; + metadata: Record; + children: DraftRun[]; +} + +interface ExportSessionInput { + projectId: string; + agentId: string; + threadId: string; + user: User; +} + +@Service() +export class AgentSessionLangSmithExportService { + constructor( + private readonly logger: Logger, + private readonly aiService: AiService, + private readonly outboundHttp: OutboundHttp, + private readonly agentExecutionService: AgentExecutionService, + private readonly threadRepository: AgentExecutionThreadRepository, + ) {} + + async exportSession(input: ExportSessionInput): Promise { + this.ensureEnabled(); + + const tree = await this.loadSessionTree( + input.threadId, + input.projectId, + input.agentId, + new Set(), + ); + const draft = buildSessionRun(tree, `sessions/${tree.thread.id}`); + const traceId = uuidv5(canonicalSerialize(draft), EXPORT_NAMESPACE); + const { convertToDottedOrderFormat } = await import('langsmith/run_trees'); + const runs = materializeRuns(draft, traceId, convertToDottedOrderFormat); + + try { + const client = await this.createClient(input.user); + for (const run of runs) { + await client.createRun(run); + } + } catch (error) { + this.logger.error('Failed to export agent session to LangSmith', { + projectId: input.projectId, + agentId: input.agentId, + threadId: input.threadId, + error: error instanceof Error ? error.message : String(error), + }); + throw new ServiceUnavailableError("Session couldn't be sent to LangSmith. Try again."); + } + + return { traceId }; + } + + private ensureEnabled() { + if (!this.aiService.isProxyEnabled()) { + throw new NotFoundError('LangSmith debug export is not enabled'); + } + } + + private async loadSessionTree( + threadId: string, + projectId: string, + agentId: string, + visited: Set, + ): Promise { + if (visited.has(threadId)) { + throw new ConflictError('Agent session contains a cyclic child link'); + } + visited.add(threadId); + + const detail = await this.agentExecutionService.getThreadDetail(threadId, projectId, agentId); + if (!detail) { + throw new NotFoundError(`Thread "${threadId}" not found`); + } + this.ensureSettled(detail); + + const childThreads = await this.threadRepository.findByParentThreadId(threadId, projectId); + const children: LoadedSession[] = []; + for (const child of childThreads) { + if ( + child.parentThreadId !== detail.thread.id || + child.parentAgentId !== detail.thread.agentId || + child.projectId !== detail.thread.projectId + ) { + throw new ConflictError('Agent session child link is invalid'); + } + children.push(await this.loadSessionTree(child.id, projectId, child.agentId, visited)); + } + + return { ...detail, children }; + } + + private ensureSettled(detail: ThreadDetail) { + if (detail.executions.some((execution) => execution.status === 'running')) { + throw new ConflictError('Session is still running'); + } + if ( + detail.executions.some( + (execution) => execution.storedAt !== 'db' && execution.timeline === null, + ) + ) { + throw new ServiceUnavailableError( + "Session couldn't be exported because some execution data is unavailable. Try again.", + ); + } + } + + private async createClient(user: User): Promise { + const proxyClient = await this.aiService.getClient(); + const proxyBaseUrl = proxyClient.getApiProxyBaseUrl().replace(/\/$/, ''); + const tokenManager = new ProxyTokenManager(async () => { + return await proxyClient.getBuilderApiProxyToken( + { id: user.id }, + { userMessageId: nanoid() }, + ); + }); + const proxyHeaders = buildProxyHeaders({ + feature: 'agent-builder', + n8nVersion: N8N_VERSION, + }); + const transportFetch = createAiProxyFetch(this.outboundHttp); + const fetchImplementation: typeof globalThis.fetch = async (request, init) => { + const headers = new Headers(init?.headers); + const authHeaders = await tokenManager.getAuthHeaders(); + for (const [key, value] of Object.entries({ ...proxyHeaders, ...authHeaders })) { + headers.set(key, value); + } + if (!headers.has('Accept-Encoding')) { + headers.set('Accept-Encoding', 'gzip, deflate'); + } + const response = await transportFetch(request, { ...init, headers }); + return response.status === 409 + ? new Response(null, { status: 200, statusText: 'OK (409 suppressed)' }) + : response; + }; + const { Client: LangSmithClient } = await import('langsmith'); + return new LangSmithClient({ + apiUrl: proxyBaseUrl + '/langsmith', + apiKey: '-', + autoBatchTracing: false, + fetchImplementation, + }); + } +} + +function buildSessionRun(session: LoadedSession, path: string): DraftRun { + const matchedChildren = new Set(); + const childSessions = new Map(session.children.map((child) => [child.thread.id, child])); + const executionRuns = session.executions.map((execution) => + buildExecutionRun(execution, path, childSessions, matchedChildren), + ); + const unmatchedChildren = session.children + .filter((child) => !matchedChildren.has(child.thread.id)) + .map((child) => buildSessionRun(child, `${path}/children/${child.thread.id}`)); + const startTime = + session.executions[0]?.startedAt?.getTime() ?? + session.executions[0]?.createdAt.getTime() ?? + session.thread.createdAt.getTime(); + const lastExecution = session.executions.at(-1); + const endTime = + lastExecution?.stoppedAt?.getTime() ?? + lastExecution?.updatedAt.getTime() ?? + session.thread.updatedAt.getTime(); + + return { + path, + name: `Agent session: ${session.thread.agentName}`, + runType: 'chain', + startTime, + endTime, + inputs: { + title: session.thread.title, + taskId: session.thread.taskId, + }, + outputs: { + executionCount: session.executions.length, + childSessionCount: session.children.length, + }, + metadata: { + n8nVersion: N8N_VERSION, + threadId: session.thread.id, + agentId: session.thread.agentId, + agentName: session.thread.agentName, + projectId: session.thread.projectId, + parentThreadId: session.thread.parentThreadId, + parentAgentId: session.thread.parentAgentId, + sessionNumber: session.thread.sessionNumber, + taskId: session.thread.taskId, + taskVersionId: session.thread.taskVersionId, + totalPromptTokens: session.thread.totalPromptTokens, + totalCompletionTokens: session.thread.totalCompletionTokens, + totalCost: session.thread.totalCost, + totalDuration: session.thread.totalDuration, + }, + children: [...executionRuns, ...unmatchedChildren], + }; +} + +function buildExecutionRun( + execution: AgentExecution, + sessionPath: string, + childSessions: Map, + matchedChildren: Set, +): DraftRun { + const path = `${sessionPath}/executions/${execution.id}`; + const events = execution.timeline ?? []; + const children = events.map((event, index) => { + const eventRun = buildEventRun(event, execution, `${path}/events/${index}`); + const childThreadId = delegatedChildThreadId(event); + const childSession = childThreadId ? childSessions.get(childThreadId) : undefined; + if (childSession && !matchedChildren.has(childSession.thread.id)) { + matchedChildren.add(childSession.thread.id); + eventRun.children.push( + buildSessionRun(childSession, `${eventRun.path}/children/${childSession.thread.id}`), + ); + } + return eventRun; + }); + const startTime = execution.startedAt?.getTime() ?? execution.createdAt.getTime(); + const endTime = execution.stoppedAt?.getTime() ?? execution.updatedAt.getTime(); + + return { + path, + name: 'Agent turn', + runType: 'chain', + startTime, + endTime, + inputs: { + message: execution.userMessage, + attachments: execution.attachments, + }, + outputs: { status: execution.status }, + error: execution.error ?? undefined, + metadata: executionMetadata(execution), + children, + }; +} + +function buildEventRun(event: TimelineEvent, execution: AgentExecution, path: string): DraftRun { + switch (event.type) { + case 'text': + return { + path, + name: 'Agent response', + runType: 'llm', + startTime: event.timestamp, + endTime: event.endTime ?? event.timestamp, + inputs: {}, + outputs: { text: event.content }, + metadata: executionUsageMetadata(execution), + children: [], + }; + case 'reasoning': + return { + path, + name: 'Reasoning', + runType: 'chain', + startTime: event.timestamp, + endTime: event.endTime ?? event.timestamp, + inputs: {}, + outputs: { content: event.content }, + metadata: {}, + children: [], + }; + case 'suspension': + return { + path, + name: 'Suspension', + runType: 'chain', + startTime: event.timestamp, + endTime: event.timestamp, + inputs: { + toolName: event.toolName, + toolCallId: event.toolCallId, + }, + outputs: { status: 'suspended' }, + metadata: {}, + children: [], + }; + case 'tool-call': + return { + path, + name: event.name, + runType: 'tool', + startTime: event.startTime, + endTime: event.endTime, + inputs: toRecord(event.input), + outputs: { + ...toRecord(event.output), + ...(event.childTrace ? { childTrace: event.childTrace } : {}), + }, + error: event.success ? undefined : toolError(event.output), + metadata: { + kind: event.kind, + toolCallId: event.toolCallId, + success: event.success, + workflowId: event.workflowId, + workflowName: event.workflowName, + workflowExecutionId: event.workflowExecutionId, + triggerType: event.triggerType, + nodeType: event.nodeType, + nodeTypeVersion: event.nodeTypeVersion, + nodeDisplayName: event.nodeDisplayName, + nodeParameters: event.nodeParameters, + }, + children: [], + }; + } +} + +function executionMetadata(execution: AgentExecution): Record { + return { + executionId: execution.id, + status: execution.status, + hitlStatus: execution.hitlStatus, + source: execution.source, + storedAt: execution.storedAt, + ...executionUsageMetadata(execution), + }; +} + +function executionUsageMetadata(execution: AgentExecution): Record { + return { + model: execution.model, + promptTokens: execution.promptTokens, + completionTokens: execution.completionTokens, + totalTokens: execution.totalTokens, + cost: execution.cost, + duration: execution.duration, + }; +} + +function delegatedChildThreadId(event: TimelineEvent): string | undefined { + if (event.type !== 'tool-call' || event.name !== 'delegate_subagent') return undefined; + if (!isRecord(event.output)) return undefined; + const { threadId } = event.output; + return typeof threadId === 'string' ? threadId : undefined; +} + +function toolError(output: unknown): string { + if (typeof output === 'string') return output; + if (isRecord(output)) { + if (typeof output.error === 'string') return output.error; + if (typeof output.message === 'string') return output.message; + } + return 'Tool call failed'; +} + +function toRecord(value: unknown): Record { + return isRecord(value) ? value : { value }; +} + +function materializeRuns( + root: DraftRun, + traceId: string, + convertToDottedOrderFormat: DottedOrderConverter, +): LangSmithRun[] { + const runs: LangSmithRun[] = []; + + const visit = ( + draft: DraftRun, + parentRunId: string | undefined, + parentDottedOrder: string | undefined, + siblingOrder: number, + ) => { + const id = parentRunId ? uuidv5(draft.path, traceId) : traceId; + const segment = convertToDottedOrderFormat(draft.startTime, id, siblingOrder).dottedOrder; + const dottedOrder = parentDottedOrder ? `${parentDottedOrder}.${segment}` : segment; + const sanitized = { + name: scrubText(draft.name), + inputs: sanitizeRecord(draft.inputs), + outputs: draft.outputs ? sanitizeRecord(draft.outputs) : undefined, + error: draft.error ? scrubText(draft.error) : undefined, + extra: { metadata: sanitizeRecord(draft.metadata) }, + }; + runs.push({ + ...sanitized, + id, + trace_id: traceId, + parent_run_id: parentRunId, + dotted_order: dottedOrder, + run_type: draft.runType, + start_time: draft.startTime, + end_time: draft.endTime, + project_name: LANGSMITH_PROJECT, + }); + + draft.children.forEach((child, index) => { + visit(child, id, dottedOrder, index + 1); + }); + }; + + visit(root, undefined, undefined, 1); + return runs; +} + +function sanitizeRecord(value: Record): Record { + const sanitized = redactDeep(value, REDACTION_OPTIONS).value; + return isRecord(sanitized) ? sanitized : {}; +} + +function scrubText(value: string): string { + return redactText(value, REDACTION_OPTIONS).text; +} + +function canonicalSerialize(value: DraftRun): string { + return ( + JSON.stringify(value, (_key, item: unknown) => + isRecord(item) + ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) + : item, + ) ?? '{}' + ); +} diff --git a/packages/cli/src/modules/agents/agent-threads.controller.ts b/packages/cli/src/modules/agents/agent-threads.controller.ts index 1674e58bbec..30f1313c6f6 100644 --- a/packages/cli/src/modules/agents/agent-threads.controller.ts +++ b/packages/cli/src/modules/agents/agent-threads.controller.ts @@ -1,13 +1,17 @@ import type { AuthenticatedRequest } from '@n8n/db'; -import { Delete, Get, ProjectScope, RestController } from '@n8n/decorators'; +import { Delete, Get, Post, ProjectScope, RestController } from '@n8n/decorators'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { AgentExecutionService } from './agent-execution.service'; +import { AgentSessionLangSmithExportService } from './agent-session-langsmith-export.service'; @RestController('/projects/:projectId/agents/v2') export class AgentThreadsController { - constructor(private readonly agentExecutionService: AgentExecutionService) {} + constructor( + private readonly agentExecutionService: AgentExecutionService, + private readonly langsmithExportService: AgentSessionLangSmithExportService, + ) {} @Get('/:agentId/threads') @ProjectScope('agent:read') @@ -44,6 +48,20 @@ export class AgentThreadsController { return result; } + @Post('/:agentId/threads/:threadId/langsmith-export') + @ProjectScope('agent:read') + async exportThreadToLangSmith( + req: AuthenticatedRequest<{ projectId: string; agentId: string; threadId: string }>, + ) { + const { projectId, agentId, threadId } = req.params; + return await this.langsmithExportService.exportSession({ + projectId, + agentId, + threadId, + user: req.user, + }); + } + @Delete('/:agentId/threads/:threadId') @ProjectScope('agent:update') async deleteThread( diff --git a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts index adc99ded6d4..efe3e6d7043 100644 --- a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts @@ -124,6 +124,16 @@ export class AgentExecutionThreadRepository extends Repository { + return await this.find({ + where: { parentThreadId, projectId }, + order: { createdAt: 'ASC' }, + }); + } + /** Bump updatedAt to now so the thread sorts to top of the list. */ async bumpUpdatedAt(threadId: string): Promise { await this.update(threadId, { updatedAt: new Date() }); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 5f2963195d4..a948ef671bc 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1450,6 +1450,13 @@ "agentSessions.origin.workflow": "Workflow", "agentSessions.goToParentRun": "Go to parent run", "agentSessions.viewTrace": "View session trace", + "agentSessions.langsmithExport.button": "Send to LangSmith", + "agentSessions.langsmithExport.confirm.title": "Send session to LangSmith?", + "agentSessions.langsmithExport.confirm.body": "This sends the conversation, tool inputs and outputs, and errors to n8n's LangSmith workspace for debugging. Check the session for sensitive data before continuing.", + "agentSessions.langsmithExport.confirm.button": "Send session", + "agentSessions.langsmithExport.success": "Session sent to LangSmith", + "agentSessions.langsmithExport.successMessage": "Trace ID: {traceId}", + "agentSessions.langsmithExport.error": "Session couldn't be sent to LangSmith. Try again.", "agentSessions.actions": "Actions", "agentSessions.empty": "No agent sessions", "agentSessions.loadMore": "Load more", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index 0c59da8b988..019c47f2895 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -1611,7 +1611,7 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => { try { editorColumn.vm.$emit('toggle-mcp-access', true); await nextTick(); - await vi.advanceTimersByTimeAsync(500); + await vi.runOnlyPendingTimersAsync(); expect(toggleAgentMcpAccess).toHaveBeenCalledExactlyOnceWith('a2', true); } finally { vi.useRealTimers(); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts index a45baf9bb32..7362c2d8da9 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts @@ -69,6 +69,14 @@ vi.mock('@/app/composables/useKeybindings', () => ({ useKeybindings: vi.fn(), })); +vi.mock('../composables/useAgentSessionLangSmithExport', () => ({ + useAgentSessionLangSmithExport: () => ({ + isEnabled: false, + isExporting: false, + sendSession: vi.fn(), + }), +})); + // Reads a Pinia store for notifications — irrelevant to panel behavior. vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showMessage: vi.fn() }), diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentHeaders.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentHeaders.test.ts index 66d7a70a5b5..6f598c24659 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentHeaders.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentHeaders.test.ts @@ -82,6 +82,8 @@ describe('AgentSessionTimelineHeader', () => { totalTokens: 1234, totalCost: 0.1234, durationLabel: '1.2s', + showLangsmithExport: false, + langsmithExportLoading: false, }, }); } diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewDock.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewDock.test.ts index 43e4c788eee..c57473d8fc3 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewDock.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewDock.test.ts @@ -14,6 +14,14 @@ vi.mock('@/app/composables/useKeybindings', () => ({ useKeybindings: useKeybindingsMock, })); +vi.mock('../composables/useAgentSessionLangSmithExport', () => ({ + useAgentSessionLangSmithExport: () => ({ + isEnabled: false, + isExporting: false, + sendSession: vi.fn(), + }), +})); + vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (key: string) => key }), })); diff --git a/packages/frontend/editor-ui/src/features/agents/agentSessions.store.ts b/packages/frontend/editor-ui/src/features/agents/agentSessions.store.ts index 8babf1a8048..db8b5563c53 100644 --- a/packages/frontend/editor-ui/src/features/agents/agentSessions.store.ts +++ b/packages/frontend/editor-ui/src/features/agents/agentSessions.store.ts @@ -5,6 +5,7 @@ import { listThreads, getThreadDetail as getThreadDetailApi, deleteThread as deleteThreadApi, + exportThreadToLangSmith as exportThreadToLangSmithApi, type AgentExecutionThread, type ThreadDetail, } from './composables/useAgentThreadsApi'; @@ -148,6 +149,11 @@ export const useAgentSessionsStore = defineStore('agentSessions', () => { threads.value = threads.value.filter((t) => t.id !== threadId); } + async function exportThreadToLangSmith(projectId: string, agentId: string, threadId: string) { + const rootStore = useRootStore(); + return await exportThreadToLangSmithApi(rootStore.restApiContext, projectId, agentId, threadId); + } + function scheduleAutoRefresh() { if (!autoRefreshActive || !autoRefresh.value || !currentProjectId || !currentAgentId) return; refreshTimer = setTimeout(async () => { @@ -195,6 +201,7 @@ export const useAgentSessionsStore = defineStore('agentSessions', () => { getThreadDetail, upsertThread, deleteThread, + exportThreadToLangSmith, startAutoRefresh, stopAutoRefresh, reset, diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewDock.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewDock.vue index dec4e926404..60e81de37d8 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewDock.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewDock.vue @@ -6,6 +6,7 @@ import { useTemplateRef } from 'vue'; import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue'; import { useKeybindings } from '@/app/composables/useKeybindings'; +import { useAgentSessionLangSmithExport } from '../composables/useAgentSessionLangSmithExport'; import type { AgentContinueLoadedEvent, AgentFixWithAssistantEvent, @@ -40,12 +41,26 @@ const emit = defineEmits<{ const i18n = useI18n(); const dock = useTemplateRef('dock'); +const { + isEnabled: isLangSmithExportEnabled, + isExporting, + sendSession, +} = useAgentSessionLangSmithExport(); function viewTrace() { if (!props.hasSession || !props.effectiveSessionId) return; emit('view-trace'); } +function exportSession() { + if (!props.hasSession || !props.effectiveSessionId) return; + void sendSession({ + projectId: props.projectId, + agentId: props.agentId, + threadId: props.effectiveSessionId, + }); +} + function createNewSession() { emit('new-session'); } @@ -103,6 +118,25 @@ useKeybindings({ /> + + + + -import { N8nBreadcrumbs, N8nButton, N8nDropdownMenu, N8nIcon } from '@n8n/design-system'; -import type { DropdownMenuItemProps } from '@n8n/design-system'; -import type { PathItem } from '@n8n/design-system'; +import { + N8nBreadcrumbs, + N8nButton, + N8nDropdownMenu, + N8nIcon, + N8nIconButton, + N8nTooltip, + TOOLTIP_DELAY_MS, + type DropdownMenuItemProps, + type PathItem, +} from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; interface SessionDropdownData { @@ -20,11 +28,14 @@ const props = defineProps<{ totalTokens: number; totalCost: number; durationLabel: string; + showLangsmithExport: boolean; + langsmithExportLoading: boolean; }>(); const emit = defineEmits<{ 'breadcrumb-select': [item: PathItem]; 'session-select': [sessionId: string]; + 'langsmith-export': []; close: []; }>(); @@ -74,6 +85,23 @@ const i18n = useI18n();
+ + + {{ props.triggerLabel }} diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentSessionLangSmithExport.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentSessionLangSmithExport.ts new file mode 100644 index 00000000000..90b836ada4e --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentSessionLangSmithExport.ts @@ -0,0 +1,62 @@ +import { useClipboard } from '@n8n/composables/useClipboard'; +import { useToast } from '@n8n/composables/useToast'; +import { useI18n } from '@n8n/i18n'; +import { useSettingsStore } from '@n8n/stores/settings.store'; +import { computed, ref } from 'vue'; + +import { MODAL_CONFIRM } from '@/app/constants'; + +import { useAgentSessionsStore } from '../agentSessions.store'; +import { useAgentConfirmationModal } from './useAgentConfirmationModal'; + +interface AgentSessionIdentifiers { + projectId: string; + agentId: string; + threadId: string; +} + +export function useAgentSessionLangSmithExport() { + const i18n = useI18n(); + const settingsStore = useSettingsStore(); + const sessionsStore = useAgentSessionsStore(); + const clipboard = useClipboard(); + const { showError, showMessage } = useToast(); + const { openAgentConfirmationModal } = useAgentConfirmationModal(); + const isExporting = ref(false); + const isEnabled = computed( + () => + localStorage.getItem('instanceAi.debugMode') === 'true' && + settingsStore.moduleSettings.agents?.proxyEnabled === true, + ); + + async function sendSession({ projectId, agentId, threadId }: AgentSessionIdentifiers) { + if (!isEnabled.value || isExporting.value) return; + + isExporting.value = true; + try { + const confirmed = await openAgentConfirmationModal({ + title: i18n.baseText('agentSessions.langsmithExport.confirm.title'), + description: i18n.baseText('agentSessions.langsmithExport.confirm.body'), + confirmButtonText: i18n.baseText('agentSessions.langsmithExport.confirm.button'), + cancelButtonText: i18n.baseText('generic.cancel'), + }); + if (confirmed !== MODAL_CONFIRM) return; + + const { traceId } = await sessionsStore.exportThreadToLangSmith(projectId, agentId, threadId); + await clipboard.copy(traceId).catch(() => {}); + showMessage({ + title: i18n.baseText('agentSessions.langsmithExport.success'), + message: i18n.baseText('agentSessions.langsmithExport.successMessage', { + interpolate: { traceId }, + }), + type: 'success', + }); + } catch (error) { + showError(error, i18n.baseText('agentSessions.langsmithExport.error')); + } finally { + isExporting.value = false; + } + } + + return { isEnabled, isExporting, sendSession }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentThreadsApi.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentThreadsApi.ts index 2a657bc58fc..3843b074bce 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentThreadsApi.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentThreadsApi.ts @@ -1,3 +1,4 @@ +import type { AgentSessionLangSmithExportResponse } from '@n8n/api-types'; import { makeRestApiRequest } from '@n8n/rest-api-client'; import type { IRestApiContext } from '@n8n/rest-api-client'; @@ -116,3 +117,16 @@ export const deleteThread = async ( `/projects/${projectId}/agents/v2/${agentId}/threads/${threadId}`, ); }; + +export const exportThreadToLangSmith = async ( + context: IRestApiContext, + projectId: string, + agentId: string, + threadId: string, +): Promise => { + return await makeRestApiRequest( + context, + 'POST', + `/projects/${projectId}/agents/v2/${agentId}/threads/${threadId}/langsmith-export`, + ); +}; diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue index 43681194cc7..ec24963d575 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue @@ -9,6 +9,7 @@ import { AGENT_SESSION_DETAIL_VIEW, EXECUTIONS_SECTION_KEY, } from '@/features/agents/constants'; +import { useAgentSessionLangSmithExport } from '@/features/agents/composables/useAgentSessionLangSmithExport'; import { useThreadTitle } from '@/features/agents/utils/thread-title'; import type { AgentExecution, @@ -18,8 +19,7 @@ import type { import AgentSessionTimelineHeader from '@/features/agents/components/AgentSessionTimelineHeader.vue'; import AgentSessionTimelinePanel from '@/features/agents/components/AgentSessionTimelinePanel.vue'; import { useI18n } from '@n8n/i18n'; -import type { PathItem } from '@n8n/design-system'; -import type { DropdownMenuItemProps } from '@n8n/design-system'; +import type { DropdownMenuItemProps, PathItem } from '@n8n/design-system'; import { computed, ref, watch } from 'vue'; import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router'; @@ -29,6 +29,11 @@ const route = useRoute(); const router = useRouter(); const sessionsStore = useAgentSessionsStore(); const projectsStore = useProjectsStore(); +const { + isEnabled: isLangSmithExportEnabled, + isExporting, + sendSession, +} = useAgentSessionLangSmithExport(); const projectId = computed(() => route.params.projectId as string); const agentId = computed(() => route.params.agentId as string); @@ -130,6 +135,7 @@ const totalTokens = computed(() => { return thread.value.totalPromptTokens + thread.value.totalCompletionTokens; }); +const hasLoadedThread = computed(() => thread.value?.id === threadId.value); const totalCost = computed(() => thread.value?.totalCost ?? 0); const durationLabel = computed(() => formatDuration(thread.value?.totalDuration ?? 0)); @@ -203,8 +209,11 @@ function onSessionSelect(nextThreadId: string) { :total-tokens="totalTokens" :total-cost="totalCost" :duration-label="durationLabel" + :show-langsmith-export="isLangSmithExportEnabled && hasLoadedThread" + :langsmith-export-loading="isExporting" @breadcrumb-select="onBreadcrumbSelect" @session-select="onSessionSelect" + @langsmith-export="sendSession({ projectId, agentId, threadId })" @close="closeTimeline" />