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