feat: Add LangSmith agent session debug export (#36309)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Braumann 2026-08-17 11:02:00 +00:00 committed by GitHub
parent 02bc2bf533
commit 6cd9a2bfdb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1116 additions and 11 deletions

View File

@ -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<string, unknown> = {};
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;
}

View File

@ -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.

View File

@ -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> = {}): 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> = {}): 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<AgentExecutionService>();
const threadRepository = mock<AgentExecutionThreadRepository>();
const aiService = mock<AiService>();
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<HttpTransport>();
transport.asCustomFetch.mockReturnValue(vi.fn() as unknown as CustomFetch);
const outboundHttp = mock<OutboundHttp>();
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<User>({ 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<void>((resolve) => {
releaseLastRun = resolve;
});
createRunMock.mockImplementation(async (run: { outputs?: Record<string, unknown> }) => {
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.",
);
});
});

View File

@ -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);

View File

@ -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<Client['createRun']>[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<string, unknown>;
outputs?: Record<string, unknown>;
error?: string;
metadata: Record<string, unknown>;
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<AgentSessionLangSmithExportResponse> {
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<string>,
): Promise<LoadedSession> {
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<Client> {
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<string>();
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<string, LoadedSession>,
matchedChildren: Set<string>,
): 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<string, unknown> {
return {
executionId: execution.id,
status: execution.status,
hitlStatus: execution.hitlStatus,
source: execution.source,
storedAt: execution.storedAt,
...executionUsageMetadata(execution),
};
}
function executionUsageMetadata(execution: AgentExecution): Record<string, unknown> {
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<string, unknown> {
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<string, unknown>): Record<string, unknown> {
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,
) ?? '{}'
);
}

View File

@ -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(

View File

@ -124,6 +124,16 @@ export class AgentExecutionThreadRepository extends Repository<AgentExecutionThr
};
}
async findByParentThreadId(
parentThreadId: string,
projectId: string,
): Promise<AgentExecutionThread[]> {
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<void> {
await this.update(threadId, { updatedAt: new Date() });

View File

@ -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",

View File

@ -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();

View File

@ -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() }),

View File

@ -82,6 +82,8 @@ describe('AgentSessionTimelineHeader', () => {
totalTokens: 1234,
totalCost: 0.1234,
durationLabel: '1.2s',
showLangsmithExport: false,
langsmithExportLoading: false,
},
});
}

View File

@ -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 }),
}));

View File

@ -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,

View File

@ -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<HTMLElement>('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({
/>
</N8nTooltip>
<N8nTooltip
v-if="isLangSmithExportEnabled && props.hasSession && props.effectiveSessionId"
:content="i18n.baseText('agentSessions.langsmithExport.button')"
placement="bottom"
:show-after="TOOLTIP_DELAY_MS"
data-testid="agent-preview-langsmith-export-tooltip"
>
<N8nIconButton
icon="bug"
variant="ghost"
size="small"
icon-size="large"
:loading="isExporting"
:aria-label="i18n.baseText('agentSessions.langsmithExport.button')"
data-testid="agent-preview-langsmith-export-btn"
@click="exportSession"
/>
</N8nTooltip>
<KeyboardShortcutTooltip
placement="bottom"
:label="i18n.baseText('agents.builder.chat.newChat.label')"

View File

@ -1,7 +1,15 @@
<script setup lang="ts">
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();
</N8nBreadcrumbs>
</div>
<div v-if="props.showMetrics" :class="$style.topBarRight">
<N8nTooltip
v-if="props.showLangsmithExport"
:content="i18n.baseText('agentSessions.langsmithExport.button')"
placement="bottom"
:show-after="TOOLTIP_DELAY_MS"
>
<N8nIconButton
icon="bug"
variant="ghost"
size="small"
icon-size="large"
:loading="props.langsmithExportLoading"
:aria-label="i18n.baseText('agentSessions.langsmithExport.button')"
data-testid="agent-session-langsmith-export"
@click="emit('langsmith-export')"
/>
</N8nTooltip>
<span v-if="props.triggerSource" :class="$style.metricItem">
<N8nIcon :icon="props.triggerIcon" :size="12" />
<span>{{ props.triggerLabel }}</span>

View File

@ -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 };
}

View File

@ -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<AgentSessionLangSmithExportResponse> => {
return await makeRestApiRequest<AgentSessionLangSmithExportResponse>(
context,
'POST',
`/projects/${projectId}/agents/v2/${agentId}/threads/${threadId}/langsmith-export`,
);
};

View File

@ -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"
/>