mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat: Add LangSmith agent session debug export (#36309)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
02bc2bf533
commit
6cd9a2bfdb
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) ?? '{}'
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() }),
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ describe('AgentSessionTimelineHeader', () => {
|
|||
totalTokens: 1234,
|
||||
totalCost: 0.1234,
|
||||
durationLabel: '1.2s',
|
||||
showLangsmithExport: false,
|
||||
langsmithExportLoading: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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`,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user