diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 8c1eb78c974..427dea8fea3 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -10,7 +10,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | ---- | ------- | ------- | ---- | | [public.agent_chat_subscriptions](public.agent_chat_subscriptions.md) | 6 | | BASE TABLE | | [public.agent_checkpoints](public.agent_checkpoints.md) | 6 | | BASE TABLE | -| [public.agent_execution](public.agent_execution.md) | 20 | | BASE TABLE | +| [public.agent_execution](public.agent_execution.md) | 18 | | BASE TABLE | | [public.agent_execution_threads](public.agent_execution_threads.md) | 17 | | BASE TABLE | | [public.agent_files](public.agent_files.md) | 8 | | BASE TABLE | | [public.agent_history](public.agent_history.md) | 9 | | BASE TABLE | @@ -307,7 +307,6 @@ erDiagram timestamp_3__with_time_zone updatedAt } "public.agent_execution" { - text assistantResponse integer completionTokens double_precision cost timestamp_3__with_time_zone createdAt @@ -323,7 +322,6 @@ erDiagram timestamp_3__with_time_zone stoppedAt varchar_128_ threadId FK json timeline - json toolCalls integer totalTokens timestamp_3__with_time_zone updatedAt text userMessage diff --git a/docs/generated/postgres-schema/public.agent_execution.md b/docs/generated/postgres-schema/public.agent_execution.md index 9a55ebba2cb..8f31da38ce7 100644 --- a/docs/generated/postgres-schema/public.agent_execution.md +++ b/docs/generated/postgres-schema/public.agent_execution.md @@ -4,7 +4,6 @@ | Name | Type | Default | Nullable | Children | Parents | Comment | | ---- | ---- | ------- | -------- | -------- | ------- | ------- | -| assistantResponse | text | | false | | | | | completionTokens | integer | | true | | | | | cost | double precision | | true | | | | | createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | @@ -20,10 +19,9 @@ | stoppedAt | timestamp(3) with time zone | | true | | | | | threadId | varchar(128) | | false | | [public.agent_execution_threads](public.agent_execution_threads.md) | | | timeline | json | | true | | | | -| toolCalls | json | | true | | | | | totalTokens | integer | | true | | | | | updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | -| userMessage | text | | false | | | | +| userMessage | text | | true | | | | ## Constraints @@ -33,14 +31,12 @@ | CHK_agent_execution_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['success'::character varying, 'error'::character varying])::text[]))) | | FK_add2432fb6034cc18b6af299dce | FOREIGN KEY | FOREIGN KEY ("threadId") REFERENCES agent_execution_threads(id) ON DELETE CASCADE | | PK_ba438acc8532addc12d1ef17049 | PRIMARY KEY | PRIMARY KEY (id) | -| agent_execution_assistantResponse_not_null | n | NOT NULL "assistantResponse" | | agent_execution_createdAt_not_null | n | NOT NULL "createdAt" | | agent_execution_duration_not_null | n | NOT NULL duration | | agent_execution_id_not_null | n | NOT NULL id | | agent_execution_status_not_null | n | NOT NULL status | | agent_execution_threadId_not_null | n | NOT NULL "threadId" | | agent_execution_updatedAt_not_null | n | NOT NULL "updatedAt" | -| agent_execution_userMessage_not_null | n | NOT NULL "userMessage" | ## Indexes @@ -57,7 +53,6 @@ erDiagram "public.agent_execution" }o--|| "public.agent_execution_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agent_execution_threads(id) ON DELETE CASCADE" "public.agent_execution" { - text assistantResponse integer completionTokens double_precision cost timestamp_3__with_time_zone createdAt @@ -73,7 +68,6 @@ erDiagram timestamp_3__with_time_zone stoppedAt varchar_128_ threadId FK json timeline - json toolCalls integer totalTokens timestamp_3__with_time_zone updatedAt text userMessage diff --git a/docs/generated/postgres-schema/public.agent_execution_threads.md b/docs/generated/postgres-schema/public.agent_execution_threads.md index 31a97bee920..15b0082c1c0 100644 --- a/docs/generated/postgres-schema/public.agent_execution_threads.md +++ b/docs/generated/postgres-schema/public.agent_execution_threads.md @@ -94,7 +94,6 @@ erDiagram varchar_36_ versionId } "public.agent_execution" { - text assistantResponse integer completionTokens double_precision cost timestamp_3__with_time_zone createdAt @@ -110,7 +109,6 @@ erDiagram timestamp_3__with_time_zone stoppedAt varchar_128_ threadId FK json timeline - json toolCalls integer totalTokens timestamp_3__with_time_zone updatedAt text userMessage diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index c638fc73745..f36a5d309f6 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -10,7 +10,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | ---- | ------- | ------- | ---- | | [agent_chat_subscriptions](agent_chat_subscriptions.md) | 6 | | table | | [agent_checkpoints](agent_checkpoints.md) | 6 | | table | -| [agent_execution](agent_execution.md) | 20 | | table | +| [agent_execution](agent_execution.md) | 18 | | table | | [agent_execution_threads](agent_execution_threads.md) | 17 | | table | | [agent_files](agent_files.md) | 8 | | table | | [agent_history](agent_history.md) | 9 | | table | @@ -295,7 +295,6 @@ erDiagram datetime_3_ updatedAt } "agent_execution" { - TEXT assistantResponse INTEGER completionTokens REAL cost datetime_3_ createdAt @@ -311,7 +310,6 @@ erDiagram datetime_3_ stoppedAt varchar_128_ threadId FK TEXT timeline - TEXT toolCalls INTEGER totalTokens datetime_3_ updatedAt TEXT userMessage diff --git a/docs/generated/sqlite-schema/agent_execution.md b/docs/generated/sqlite-schema/agent_execution.md index 12afc2f1b7a..621bb39ac4a 100644 --- a/docs/generated/sqlite-schema/agent_execution.md +++ b/docs/generated/sqlite-schema/agent_execution.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text NOT NULL, "assistantResponse" text NOT NULL, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "toolCalls" text, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_agent_execution_status" CHECK (("status" IN ('success', 'error'))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK (("hitlStatus" IN ('suspended', 'resumed'))), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) +CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_agent_execution_status" CHECK (((("status" IN ('success', 'error'))))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK (((("hitlStatus" IN ('suspended', 'resumed'))))), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) ``` @@ -15,7 +15,6 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId | Name | Type | Default | Nullable | Children | Parents | Comment | | ---- | ---- | ------- | -------- | -------- | ------- | ------- | -| assistantResponse | TEXT | | false | | | | | completionTokens | INTEGER | | true | | | | | cost | REAL | | true | | | | | createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | @@ -31,17 +30,16 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId | stoppedAt | datetime(3) | | true | | | | | threadId | varchar(128) | | false | | [agent_execution_threads](agent_execution_threads.md) | | | timeline | TEXT | | true | | | | -| toolCalls | TEXT | | true | | | | | totalTokens | INTEGER | | true | | | | | updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | -| userMessage | TEXT | | false | | | | +| userMessage | TEXT | | true | | | | ## Constraints | Name | Type | Definition | | ---- | ---- | ---------- | -| - | CHECK | CHECK (("status" IN ('success', 'error'))) | -| - | CHECK | CHECK (("hitlStatus" IN ('suspended', 'resumed'))) | +| - | CHECK | CHECK (((("status" IN ('success', 'error'))))) | +| - | CHECK | CHECK (((("hitlStatus" IN ('suspended', 'resumed'))))) | | - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | | id | PRIMARY KEY | PRIMARY KEY (id) | | sqlite_autoindex_agent_execution_1 | PRIMARY KEY | PRIMARY KEY (id) | @@ -61,7 +59,6 @@ erDiagram "agent_execution" }o--|| "agent_execution_threads" : "FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "agent_execution" { - TEXT assistantResponse INTEGER completionTokens REAL cost datetime_3_ createdAt @@ -77,7 +74,6 @@ erDiagram datetime_3_ stoppedAt varchar_128_ threadId FK TEXT timeline - TEXT toolCalls INTEGER totalTokens datetime_3_ updatedAt TEXT userMessage diff --git a/docs/generated/sqlite-schema/agent_execution_threads.md b/docs/generated/sqlite-schema/agent_execution_threads.md index d16c6136d9b..f71131ff3be 100644 --- a/docs/generated/sqlite-schema/agent_execution_threads.md +++ b/docs/generated/sqlite-schema/agent_execution_threads.md @@ -95,7 +95,6 @@ erDiagram varchar_36_ versionId } "agent_execution" { - TEXT assistantResponse INTEGER completionTokens REAL cost datetime_3_ createdAt @@ -111,7 +110,6 @@ erDiagram datetime_3_ stoppedAt varchar_128_ threadId FK TEXT timeline - TEXT toolCalls INTEGER totalTokens datetime_3_ updatedAt TEXT userMessage diff --git a/packages/@n8n/db/src/migrations/common/1784000000039-DropAgentExecutionFallbackColumns.ts b/packages/@n8n/db/src/migrations/common/1784000000039-DropAgentExecutionFallbackColumns.ts new file mode 100644 index 00000000000..0f9470e85d1 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1784000000039-DropAgentExecutionFallbackColumns.ts @@ -0,0 +1,28 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class DropAgentExecutionFallbackColumns1784000000039 implements ReversibleMigration { + async up({ schemaBuilder: { dropColumns, dropNotNull } }: MigrationContext) { + await dropColumns('agent_execution', ['assistantResponse', 'toolCalls'], { + recreatesOnSqlite: true, + }); + await dropNotNull('agent_execution', 'userMessage', { recreatesOnSqlite: true }); + } + + async down(ctx: MigrationContext) { + const { + escape, + schemaBuilder: { addColumns, addNotNull, column }, + } = ctx; + + await addColumns( + 'agent_execution', + [column('assistantResponse').text.notNull.default("''"), column('toolCalls').json], + { recreatesOnSqlite: true }, + ); + + await ctx.runQuery( + `UPDATE ${escape.tableName('agent_execution')} SET ${escape.columnName('userMessage')} = '' WHERE ${escape.columnName('userMessage')} IS NULL`, + ); + await addNotNull('agent_execution', 'userMessage', { recreatesOnSqlite: true }); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 761e8a98910..66d7a858029 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -212,6 +212,7 @@ import { AddUniqueAgentFileNames1784000000035 } from '../common/1784000000035-Ad import { CreateInstanceAiThreadGrantTable1784000000036 } from '../common/1784000000036-CreateInstanceAiThreadGrantTable'; import { DropAgentDescriptionFromAgents1784000000037 } from '../common/1784000000037-DropAgentDescriptionFromAgents'; import { SetChatHubEnabledFromUsage1784000000038 } from '../common/1784000000038-SetChatHubEnabledFromUsage'; +import { DropAgentExecutionFallbackColumns1784000000039 } from '../common/1784000000039-DropAgentExecutionFallbackColumns'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -429,4 +430,5 @@ export const postgresMigrations: Migration[] = [ CreateInstanceAiThreadGrantTable1784000000036, DropAgentDescriptionFromAgents1784000000037, SetChatHubEnabledFromUsage1784000000038, + DropAgentExecutionFallbackColumns1784000000039, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index caa78d221b6..c1e9ab80ea1 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -205,6 +205,7 @@ import { AddBinaryDataSizeBytesToExecutionEntity1784000000033 } from '../common/ import { AddUniqueAgentFileNames1784000000035 } from '../common/1784000000035-AddUniqueAgentFileNames'; import { CreateInstanceAiThreadGrantTable1784000000036 } from '../common/1784000000036-CreateInstanceAiThreadGrantTable'; import { SetChatHubEnabledFromUsage1784000000038 } from '../common/1784000000038-SetChatHubEnabledFromUsage'; +import { DropAgentExecutionFallbackColumns1784000000039 } from '../common/1784000000039-DropAgentExecutionFallbackColumns'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -413,6 +414,7 @@ const sqliteMigrations: Migration[] = [ CreateInstanceAiThreadGrantTable1784000000036, DropAgentDescriptionFromAgents1784000000037, SetChatHubEnabledFromUsage1784000000038, + DropAgentExecutionFallbackColumns1784000000039, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts index d263a88b099..57a41b85d1f 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts @@ -375,7 +375,13 @@ describe('AgentExecutionOrchestratorService', () => { const { service, executionService } = makeService(); executionService.getThreadDetail.mockResolvedValue({ thread: { id: 'thread-1' }, - executions: [{ id: 'execution-1', userMessage: 'Hi', assistantResponse: 'Hello' }], + executions: [ + { + id: 'execution-1', + userMessage: 'Hi', + timeline: [{ type: 'text', content: 'Hello', timestamp: 100 }], + }, + ], } as never); await expect( @@ -433,7 +439,7 @@ describe('AgentExecutionOrchestratorService', () => { expect(executionService.recordMessage).toHaveBeenCalledWith( expect.objectContaining({ threadId: 'thread-1', - userMessage: '', + userMessage: null, hitlStatus: 'resumed', telemetry: { runType: 'production', @@ -472,7 +478,7 @@ describe('AgentExecutionOrchestratorService', () => { ); expect(executionService.recordMessage).toHaveBeenCalledWith( - expect.objectContaining({ threadId: 'thread-1', userMessage: '', hitlStatus: 'suspended' }), + expect.objectContaining({ threadId: 'thread-1', userMessage: null, hitlStatus: 'suspended' }), ); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts index b451024415d..28ac5c2d8cd 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts @@ -42,7 +42,6 @@ function makeMessageRecord(overrides: Partial = {}): MessageRecor finishReason: 'stop', usage: null, totalCost: null, - toolCalls: [], timeline: [], startTime: 0, duration: 1, @@ -87,7 +86,6 @@ describe('AgentExecutionService', () => { finishReason: 'stop', usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, totalCost: 0.01, - toolCalls: [], timeline: [], startTime: Date.parse('2026-05-07T10:00:00Z'), duration: 1234, @@ -223,7 +221,19 @@ describe('AgentExecutionService', () => { record: makeMessageRecord({ usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, totalCost: 25, - toolCalls: [{ name: 'lookup', input: {}, output: {} }], + timeline: [ + { + type: 'tool-call', + kind: 'tool', + name: 'lookup', + toolCallId: 'tc1', + input: {}, + output: {}, + startTime: 0, + endTime: 123, + success: true, + }, + ], duration: 123, }), telemetry: { diff --git a/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts b/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts index d34ee3c834d..fe91b6a39f1 100644 --- a/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts +++ b/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts @@ -175,50 +175,8 @@ describe('ExecutionRecorder', () => { }); }); - describe('backward compat', () => { - it('still populates flat toolCalls array', () => { - const recorder = new ExecutionRecorder(); - - recorder.record(makeToolCallChunk('my_tool', { x: 1 })); - recorder.record(makeToolResultChunk('my_tool', { y: 2 })); - recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); - - const record = recorder.getMessageRecord(); - - expect(record.toolCalls).toHaveLength(1); - expect(record.toolCalls[0]).toEqual({ - name: 'my_tool', - input: { x: 1 }, - output: { y: 2 }, - }); - }); - - it('pairs same-name flat tool calls by toolCallId when results arrive out of order', () => { - const recorder = new ExecutionRecorder(); - - recorder.record(makeToolCallChunk('same_name_tool', { file: 'first' }, 'call-1')); - recorder.record(makeToolCallChunk('same_name_tool', { file: 'second' }, 'call-2')); - recorder.record(makeToolResultChunk('same_name_tool', { result: 'second' }, 'call-2')); - recorder.record(makeToolResultChunk('same_name_tool', { result: 'first' }, 'call-1')); - recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); - - const record = recorder.getMessageRecord(); - - expect(record.toolCalls).toEqual([ - { - name: 'same_name_tool', - input: { file: 'first' }, - output: { result: 'first' }, - }, - { - name: 'same_name_tool', - input: { file: 'second' }, - output: { result: 'second' }, - }, - ]); - }); - - it('still concatenates assistantResponse from all text deltas', () => { + describe('message record', () => { + it('concatenates assistantResponse from all text deltas', () => { const recorder = new ExecutionRecorder(); recorder.record({ type: 'text-delta', id: 't1', delta: 'Hello ' }); @@ -233,7 +191,7 @@ describe('ExecutionRecorder', () => { }); describe('secret scrubbing', () => { - it('sanitizes tool inputs and outputs in flat records and timeline entries', () => { + it('sanitizes tool inputs and outputs in timeline entries', () => { const recorder = new ExecutionRecorder(); recorder.record( @@ -253,18 +211,6 @@ describe('ExecutionRecorder', () => { const record = recorder.getMessageRecord(); const timelineEntry = record.timeline.find((e) => e.type === 'tool-call'); - expect(record.toolCalls[0]).toEqual({ - name: 'lookup', - input: { - query: 'project status', - password: '[REDACTED]', - nested: { apiKey: '[REDACTED]' }, - }, - output: { - result: '[REDACTED]', - authorization: '[REDACTED]', - }, - }); expect(timelineEntry).toMatchObject({ input: { query: 'project status', @@ -291,8 +237,9 @@ describe('ExecutionRecorder', () => { }); const record = recorder.getMessageRecord(); + const timelineEntry = record.timeline.find((e) => e.type === 'tool-call'); - expect(record.toolCalls[0].output).toEqual({ error: '[REDACTED]' }); + expect(timelineEntry).toMatchObject({ output: { error: '[REDACTED]' } }); }); }); }); @@ -570,12 +517,6 @@ describe('ExecutionRecorder — workflow-tool timeline tags', () => { expect(tc?.workflowName).toBe('Run WF'); expect(tc?.workflowExecutionId).toBe('e-99'); expect(tc?.success).toBe(true); - expect(record.toolCalls).toHaveLength(1); - expect(record.toolCalls[0]).toEqual({ - name: 'run-wf', - input: undefined, - output: { executionId: 'e-99', status: 'success' }, - }); }); it('leaves workflowExecutionId undefined when the output is an error with no executionId', () => { diff --git a/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts b/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts index 9ca399832dc..a54a9f44468 100644 --- a/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts +++ b/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts @@ -8,10 +8,7 @@ function execution(overrides: Partial = {}): AgentExecution { return { id: 'execution-1', userMessage: 'Hello', - assistantResponse: '', - toolCalls: null, timeline: null, - error: null, ...overrides, } as unknown as AgentExecution; } @@ -20,7 +17,6 @@ describe('execution-to-message-mapper', () => { it('maps execution timeline text and tool calls into assistant message content', () => { const result = executionToMessagesDto( execution({ - assistantResponse: 'Let me check.Done.', timeline: [ { type: 'text', content: 'Let me check.', timestamp: 100, endTime: 110 }, { @@ -112,76 +108,18 @@ describe('execution-to-message-mapper', () => { ]); }); - it('falls back to recorded tool calls when execution timeline is unavailable', () => { - const result = executionToMessagesDto( - execution({ - assistantResponse: 'Legacy done.', - toolCalls: [{ name: 'legacy_tool', input: { id: '123' }, output: 'ok' }], - }), - ); - - expect(result).toEqual([ - { - id: 'execution-1:user', - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - { - id: 'execution-1:assistant', - role: 'assistant', - content: [ - { - type: 'tool-call', - toolName: 'legacy_tool', - toolCallId: 'execution-1:tool:0', - input: { id: '123' }, - output: 'ok', - }, - { type: 'text', text: 'Legacy done.' }, - ], - }, - ]); - }); - - it('does not infer resolved state from legacy recorded tool call output', () => { - const result = executionToMessagesDto( - execution({ - toolCalls: [ - { - name: 'legacy_tool', - input: { id: '123' }, - output: { message: 'Tool failed before timeline recording was available' }, - }, - ], - }), - ); - - expect(result).toEqual([ - { - id: 'execution-1:user', - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - { - id: 'execution-1:assistant', - role: 'assistant', - content: [ - { - type: 'tool-call', - toolName: 'legacy_tool', - toolCallId: 'execution-1:tool:0', - input: { id: '123' }, - output: { message: 'Tool failed before timeline recording was available' }, - }, - ], - }, - ]); - }); - it('flattens multiple executions into a single message list', () => { const result = executionsToMessagesDto([ - execution({ id: 'execution-1', userMessage: 'Hello', assistantResponse: 'Hi' }), - execution({ id: 'execution-2', userMessage: 'Again', assistantResponse: 'There' }), + execution({ + id: 'execution-1', + userMessage: 'Hello', + timeline: [{ type: 'text', content: 'Hi', timestamp: 100 }], + }), + execution({ + id: 'execution-2', + userMessage: 'Again', + timeline: [{ type: 'text', content: 'There', timestamp: 200 }], + }), ]); expect(result.map((message) => message.id)).toEqual([ @@ -224,7 +162,7 @@ describe('execution-to-message-mapper', () => { }), execution({ id: 'execution-resumed', - userMessage: '', + userMessage: null, timeline: [ { type: 'tool-call', diff --git a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts index a59183911b7..db4ccbf765a 100644 --- a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts +++ b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts @@ -280,7 +280,7 @@ export class AgentExecutionOrchestratorService { agentId, agentName: agentInstance.name, projectId, - userMessage: '', + userMessage: null, record: messageRecord, hitlStatus: recorder.suspended ? 'suspended' : 'resumed', telemetry: { diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts index a0b49b7f2de..0c5f6541a0a 100644 --- a/packages/cli/src/modules/agents/agent-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-execution.service.ts @@ -17,7 +17,7 @@ export interface RecordMessageParams { agentId: string; agentName: string; projectId: string; - userMessage: string; + userMessage: string | null; record: MessageRecord; /** Set to 'suspended' or 'resumed' for HITL tool call flows. */ hitlStatus?: 'suspended' | 'resumed'; @@ -41,7 +41,7 @@ export interface ThreadDetail { executions: AgentExecution[]; } -export interface ThreadListItem extends AgentExecutionThread { +export interface ThreadListItem extends Omit { firstMessage: string | null; } @@ -92,10 +92,16 @@ export class AgentExecutionService { } // Replace platform mentions (e.g. Slack's <@U0ANB4K6611> or plain @U0ANB4K6611) - const cleanedMessage = params.userMessage - .replace(/<@[A-Z0-9]+>/gi, `@${agentName}`) - .replace(/@[A-Z0-9]{8,}/gi, `@${agentName}`) - .trim(); + const userMessage = + params.userMessage === null + ? null + : (() => { + const cleanedMessage = params.userMessage + .replace(/<@[A-Z0-9]+>/gi, `@${agentName}`) + .replace(/@[A-Z0-9]{8,}/gi, `@${agentName}`) + .trim(); + return cleanedMessage.length > 0 ? cleanedMessage : null; + })(); const status: AgentExecution['status'] = record.error ? 'error' : 'success'; const startedAt = new Date(record.startTime); @@ -108,14 +114,12 @@ export class AgentExecutionService { startedAt, stoppedAt, duration: record.duration, - userMessage: cleanedMessage, - assistantResponse: record.assistantResponse, + userMessage, model: record.model, promptTokens: record.usage?.promptTokens ?? null, completionTokens: record.usage?.completionTokens ?? null, totalTokens: record.usage?.totalTokens ?? null, cost: record.totalCost, - toolCalls: record.toolCalls.length > 0 ? record.toolCalls : null, timeline: record.timeline.length > 0 ? record.timeline : null, error: record.error, hitlStatus: hitlStatus ?? null, @@ -151,7 +155,7 @@ export class AgentExecutionService { configuration: params.telemetry.configuration, latency_ms: record.duration, cost: record.totalCost ?? 0, - tool_call_count: record.toolCalls.length, + tool_call_count: record.timeline.filter((t) => t.type === 'tool-call').length, }); } catch (error) { this.logger.warn('Failed to track agent execution telemetry', { diff --git a/packages/cli/src/modules/agents/entities/agent-execution.entity.ts b/packages/cli/src/modules/agents/entities/agent-execution.entity.ts index e0c5c002f20..54b4325c012 100644 --- a/packages/cli/src/modules/agents/entities/agent-execution.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-execution.entity.ts @@ -2,7 +2,7 @@ import { DateTimeColumn, JsonColumn, WithTimestampsAndStringId } from '@n8n/db'; import { Column, Entity, Index, JoinColumn, ManyToOne } from '@n8n/typeorm'; import { AgentExecutionThread } from './agent-execution-thread.entity'; -import type { RecordedToolCall, TimelineEvent } from '../execution-recorder'; +import type { TimelineEvent } from '../execution-recorder'; export type AgentExecutionStatus = 'success' | 'error'; export type AgentExecutionHitlStatus = 'suspended' | 'resumed'; @@ -44,15 +44,9 @@ export class AgentExecution extends WithTimestampsAndStringId { @Column({ type: 'int', default: 0 }) duration: number; - /** - * Cleaned user input. Empty for resumed runs (HITL continuations) where - * the user input belongs to an earlier suspended run in the same thread. - */ - @Column({ type: 'text' }) - userMessage: string; - - @Column({ type: 'text' }) - assistantResponse: string; + /** Cleaned user input. Null for resumed runs where the input belongs to an earlier run. */ + @Column({ type: 'text', nullable: true }) + userMessage: string | null; @Column({ type: 'varchar', length: 255, nullable: true }) model: string | null; @@ -69,9 +63,6 @@ export class AgentExecution extends WithTimestampsAndStringId { @Column({ type: 'double precision', nullable: true }) cost: number | null; - @JsonColumn({ nullable: true }) - toolCalls: RecordedToolCall[] | null; - @JsonColumn({ nullable: true }) timeline: TimelineEvent[] | null; diff --git a/packages/cli/src/modules/agents/execution-recorder.ts b/packages/cli/src/modules/agents/execution-recorder.ts index d803cfbc9c7..2c22e441aad 100644 --- a/packages/cli/src/modules/agents/execution-recorder.ts +++ b/packages/cli/src/modules/agents/execution-recorder.ts @@ -197,14 +197,6 @@ export interface RecordedUsage { totalTokens: number; } -export interface RecordedToolCall { - name: string; - input: unknown; - output: unknown; -} - -type PendingRecordedToolCall = RecordedToolCall & { toolCallId?: string }; - export type TimelineEvent = | { type: 'text'; content: string; timestamp: number; endTime?: number } | { @@ -244,7 +236,6 @@ export interface MessageRecord { finishReason: string; usage: RecordedUsage | null; totalCost: number | null; - toolCalls: RecordedToolCall[]; timeline: TimelineEvent[]; startTime: number; duration: number; @@ -271,8 +262,6 @@ export class ExecutionRecorder { private totalCost: number | null = null; - private toolCalls: PendingRecordedToolCall[] = []; - private timeline: TimelineEvent[] = []; /** Wall-clock when the first text-delta of the current segment arrived. */ @@ -353,7 +342,6 @@ export class ExecutionRecorder { finishReason: this.finishReason, usage: this.usage, totalCost: this.totalCost, - toolCalls: this.toolCalls.map(({ toolCallId: _toolCallId, ...toolCall }) => toolCall), timeline: this.timeline, startTime: this.startTime, duration: Date.now() - this.startTime, @@ -381,15 +369,13 @@ export class ExecutionRecorder { } /** - * Record a discrete `tool-call` chunk from the stream. Maintains both the - * flat `toolCalls` array (backward compat) and the ordered timeline. The - * matching `tool-result` chunk closes the timeline entry. + * Record a discrete `tool-call` chunk from the stream. The matching + * `tool-result` chunk closes the timeline entry. */ private recordToolCall(toolCallId: string, name: string, input: unknown): void { this.flushTextBuffer(); const recordedInput = sanitizeExecutionLogValue(input); - this.toolCalls.push({ name, input: recordedInput, output: undefined, toolCallId }); const entry = this.registry.get(name); // Resolve both `$fromAI(...)` placeholders and simple `={{ $json.x }}` @@ -462,19 +448,6 @@ export class ExecutionRecorder { ); } - /** - * Find the still-open flat tool-call entry to attach a result to. Prefers - * an exact match on `toolCallId`; when the stream omits the id (empty - * string), falls back to the most recent open entry (`output === undefined`) - * with the same tool name. - */ - private findOpenToolCall(toolCallId: string, name: string): PendingRecordedToolCall | undefined { - if (toolCallId !== '') { - return this.toolCalls.find((tc) => tc.toolCallId === toolCallId && tc.output === undefined); - } - return [...this.toolCalls].reverse().find((tc) => tc.name === name && tc.output === undefined); - } - /** * Record a discrete `tool-result` chunk from the stream. Closes the * matching open timeline entry by `toolCallId` (preferred) or by name as @@ -495,13 +468,6 @@ export class ExecutionRecorder { isError ? normaliseToolErrorOutput(output) : output, ); - const pendingFlat = this.findOpenToolCall(toolCallId, name); - if (pendingFlat) { - pendingFlat.output = recordedOutput; - } else { - this.toolCalls.push({ name, input: undefined, output: recordedOutput }); - } - const pendingTimeline = [...this.timeline] .reverse() .find( diff --git a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts index 357a75e2dec..679e7970973 100644 --- a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts @@ -19,7 +19,7 @@ export class AgentExecutionRepository extends Repository { * sessions list to render a preview before the LLM-generated title is * available. * - * Excludes resumed runs (empty `userMessage`). Returns one row per thread + * Excludes resumed runs (null `userMessage`). Returns one row per thread * containing the userMessage from that thread's earliest matching run. */ async findFirstUserMessageByThreadIds(threadIds: string[]): Promise> { @@ -33,10 +33,12 @@ export class AgentExecutionRepository extends Repository { const rows = await this.createQueryBuilder('e') .select(['e."threadId" AS "threadId"', 'e."userMessage" AS "userMessage"']) .where('e."threadId" IN (:...threadIds)', { threadIds }) + .andWhere('e."userMessage" IS NOT NULL') .andWhere('e."userMessage" != \'\'') .andWhere( `e."createdAt" = (SELECT MIN(e2."createdAt") FROM ${tableName} e2 ` + - 'WHERE e2."threadId" = e."threadId" AND e2."userMessage" != \'\')', + 'WHERE e2."threadId" = e."threadId" AND e2."userMessage" IS NOT NULL ' + + 'AND e2."userMessage" != \'\')', ) .getRawMany<{ threadId: string; userMessage: string }>(); diff --git a/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts b/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts index 408b364c7f4..2d241a121cd 100644 --- a/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts +++ b/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts @@ -2,12 +2,9 @@ import type { AgentPersistedMessageContentPart, AgentPersistedMessageDto } from import { isRecord } from '@n8n/utils'; import type { AgentExecution } from '../entities/agent-execution.entity'; -import type { RecordedToolCall, TimelineEvent } from '../execution-recorder'; +import type { TimelineEvent } from '../execution-recorder'; -type ExecutionTranscript = Pick< - AgentExecution, - 'id' | 'userMessage' | 'assistantResponse' | 'toolCalls' | 'timeline' | 'error' ->; +type ExecutionTranscript = Pick; type ToolCallTimelineEvent = Extract; type ToolCallContentPart = AgentPersistedMessageContentPart & { @@ -23,8 +20,9 @@ function textPart(text: string): AgentPersistedMessageContentPart | null { function textMessageDto( id: string, role: AgentPersistedMessageDto['role'], - text: string, + text: string | null, ): AgentPersistedMessageDto | null { + if (!text) return null; const contentPart = textPart(text); if (!contentPart) return null; @@ -115,59 +113,22 @@ function timelineToolCallToPart(event: ToolCallTimelineEvent): AgentPersistedMes }; } -function recordedToolCallToPart( - executionId: string, - index: number, - toolCall: RecordedToolCall, -): AgentPersistedMessageContentPart { - const base: AgentPersistedMessageContentPart = { - type: 'tool-call', - toolName: toolCall.name, - toolCallId: `${executionId}:tool:${index}`, - input: toolCall.input, - }; - - if (toolCall.output === undefined) return base; - - return { - ...base, - output: toolCall.output, - }; -} - function assistantContentFromExecution( execution: ExecutionTranscript, ): AgentPersistedMessageContentPart[] { const content: AgentPersistedMessageContentPart[] = []; - let hasTimelineText = false; - let hasTimelineToolCalls = false; for (const event of execution.timeline ?? []) { if (event.type === 'text') { const part = textPart(event.content); if (!part) continue; - hasTimelineText = true; content.push(part); } else if (event.type === 'tool-call') { - hasTimelineToolCalls = true; content.push(timelineToolCallToPart(event)); } } - if (!hasTimelineToolCalls) { - for (const [index, toolCall] of (execution.toolCalls ?? []).entries()) { - content.push(recordedToolCallToPart(execution.id, index, toolCall)); - } - } - - if (!hasTimelineText) { - const fallbackText = - execution.assistantResponse || (execution.error ? `Error: ${execution.error}` : ''); - const part = textPart(fallbackText); - if (part) content.push(part); - } - return content; } diff --git a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts index 9fae27af3d5..4351381b3a5 100644 --- a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts @@ -66,8 +66,7 @@ describe('AgentExecutionRepository', () => { const execution = repository.create({ id: uuid(), status: 'success', - userMessage: '', - assistantResponse: '', + userMessage: null, ...overrides, } as Partial); return await repository.save(execution); @@ -104,12 +103,12 @@ describe('AgentExecutionRepository', () => { expect(result.size).toBe(2); }); - it('skips executions with empty user messages when picking the earliest', async () => { + it('skips executions with null user messages when picking the earliest', async () => { const thread = await createThread(); await createExecution({ threadId: thread.id, - userMessage: '', + userMessage: null, createdAt: new Date('2024-01-01T00:00:00Z'), }); await createExecution({ @@ -129,12 +128,12 @@ describe('AgentExecutionRepository', () => { expect(result.size).toBe(0); }); - it('omits threads that contain only empty user messages', async () => { + it('omits threads that contain only null user messages', async () => { const thread = await createThread(); await createExecution({ threadId: thread.id, - userMessage: '', + userMessage: null, createdAt: new Date('2024-01-01T00:00:00Z'), }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts index 95aba49717a..d303f6d940d 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts @@ -182,14 +182,12 @@ function exec(overrides: Partial = {}): AgentExecution { startedAt: '2026-04-24T10:00:00Z', stoppedAt: null, duration: 0, - userMessage: '', - assistantResponse: '', + userMessage: null, model: null, promptTokens: null, completionTokens: null, totalTokens: null, cost: null, - toolCalls: null, timeline: null, error: null, hitlStatus: null, diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentThreadsApi.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentThreadsApi.ts index 2721271434a..e3fa67613c7 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentThreadsApi.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentThreadsApi.ts @@ -33,13 +33,6 @@ export type AgentExecutionHitlStatus = 'suspended' | 'resumed'; */ export type AgentExecutionTimelineEvent = Record & { type: string }; -export interface AgentExecutionToolCall { - toolName: string; - input: unknown; - output: unknown; - [key: string]: unknown; -} - export interface AgentExecution { id: string; threadId: string; @@ -49,14 +42,12 @@ export interface AgentExecution { startedAt: string | null; stoppedAt: string | null; duration: number; - userMessage: string; - assistantResponse: string; + userMessage: string | null; model: string | null; promptTokens: number | null; completionTokens: number | null; totalTokens: number | null; cost: number | null; - toolCalls: AgentExecutionToolCall[] | null; timeline: AgentExecutionTimelineEvent[] | null; error: string | null; hitlStatus: AgentExecutionHitlStatus | null;