diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index e7e0fec8e60..d295173a2e6 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -21,7 +21,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.agent_eval_rating](public.agent_eval_rating.md) | 8 | | BASE TABLE | | [public.agent_eval_result](public.agent_eval_result.md) | 16 | | BASE TABLE | | [public.agent_eval_run](public.agent_eval_run.md) | 14 | | BASE TABLE | -| [public.agent_execution](public.agent_execution.md) | 23 | | BASE TABLE | +| [public.agent_execution](public.agent_execution.md) | 25 | | BASE TABLE | | [public.agent_execution_message_links](public.agent_execution_message_links.md) | 5 | | BASE TABLE | | [public.agent_execution_threads](public.agent_execution_threads.md) | 20 | | BASE TABLE | | [public.agent_files](public.agent_files.md) | 10 | | BASE TABLE | @@ -625,6 +625,8 @@ erDiagram integer duration text error json failureSummary + varchar_32_ finishReason + varchar_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id varchar_255_ model diff --git a/docs/generated/postgres-schema/public.agent_background_job.md b/docs/generated/postgres-schema/public.agent_background_job.md index 3b05fb80d2d..522268cc493 100644 --- a/docs/generated/postgres-schema/public.agent_background_job.md +++ b/docs/generated/postgres-schema/public.agent_background_job.md @@ -134,6 +134,8 @@ erDiagram integer duration text error json failureSummary + varchar_32_ finishReason + varchar_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id varchar_255_ model diff --git a/docs/generated/postgres-schema/public.agent_execution.md b/docs/generated/postgres-schema/public.agent_execution.md index 594ae320762..7ae1df3a8ea 100644 --- a/docs/generated/postgres-schema/public.agent_execution.md +++ b/docs/generated/postgres-schema/public.agent_execution.md @@ -13,6 +13,8 @@ | duration | integer | 0 | false | | | | | error | text | | true | | | | | failureSummary | json | | true | | | Execution failure projection as {count, latest} for session list queries | +| finishReason | varchar(32) | | true | | | Why the run ended, e.g. stop or guardrail. Null for older runs. | +| guardrailCode | varchar(64) | | true | | | Code of the guardrail that stopped the run, e.g. budget.session. | | hitlStatus | varchar(16) | | true | | | | | id | varchar(36) | | false | [public.agent_background_job](public.agent_background_job.md) [public.agent_execution_message_links](public.agent_execution_message_links.md) [public.agent_message_queue](public.agent_message_queue.md) | | | | model | varchar(255) | | true | | | | @@ -75,6 +77,8 @@ erDiagram integer duration text error json failureSummary + varchar_32_ finishReason + varchar_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id varchar_255_ model diff --git a/docs/generated/postgres-schema/public.agent_execution_message_links.md b/docs/generated/postgres-schema/public.agent_execution_message_links.md index eff53fc8d56..fe5cbd1475a 100644 --- a/docs/generated/postgres-schema/public.agent_execution_message_links.md +++ b/docs/generated/postgres-schema/public.agent_execution_message_links.md @@ -57,6 +57,8 @@ erDiagram integer duration text error json failureSummary + varchar_32_ finishReason + varchar_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id varchar_255_ model diff --git a/docs/generated/postgres-schema/public.agent_execution_threads.md b/docs/generated/postgres-schema/public.agent_execution_threads.md index f5e559f9e7e..11502ee683e 100644 --- a/docs/generated/postgres-schema/public.agent_execution_threads.md +++ b/docs/generated/postgres-schema/public.agent_execution_threads.md @@ -121,6 +121,8 @@ erDiagram integer duration text error json failureSummary + varchar_32_ finishReason + varchar_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id varchar_255_ model diff --git a/docs/generated/postgres-schema/public.agent_message_queue.md b/docs/generated/postgres-schema/public.agent_message_queue.md index ec316b58998..3fdf66a3e2d 100644 --- a/docs/generated/postgres-schema/public.agent_message_queue.md +++ b/docs/generated/postgres-schema/public.agent_message_queue.md @@ -76,6 +76,8 @@ erDiagram integer duration text error json failureSummary + varchar_32_ finishReason + varchar_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id varchar_255_ model diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index ccd78451f90..87e97ceb55a 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -21,7 +21,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [agent_eval_rating](agent_eval_rating.md) | 8 | | table | | [agent_eval_result](agent_eval_result.md) | 16 | | table | | [agent_eval_run](agent_eval_run.md) | 14 | | table | -| [agent_execution](agent_execution.md) | 23 | | table | +| [agent_execution](agent_execution.md) | 25 | | table | | [agent_execution_message_links](agent_execution_message_links.md) | 5 | | table | | [agent_execution_threads](agent_execution_threads.md) | 20 | | table | | [agent_files](agent_files.md) | 10 | | table | @@ -611,6 +611,8 @@ erDiagram INTEGER duration TEXT error TEXT failureSummary + VARCHAR_32_ finishReason + VARCHAR_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id PK varchar_255_ model diff --git a/docs/generated/sqlite-schema/agent_background_job.md b/docs/generated/sqlite-schema/agent_background_job.md index ba6f0eb4e30..fda649598f4 100644 --- a/docs/generated/sqlite-schema/agent_background_job.md +++ b/docs/generated/sqlite-schema/agent_background_job.md @@ -136,6 +136,8 @@ erDiagram INTEGER duration TEXT error TEXT failureSummary + VARCHAR_32_ finishReason + VARCHAR_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id PK varchar_255_ model diff --git a/docs/generated/sqlite-schema/agent_execution.md b/docs/generated/sqlite-schema/agent_execution.md index 11f9f28f5e0..28f6c128cde 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, "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')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "attachments" text, "failureSummary" text, "author" text, "acceptsSteering" BOOLEAN NOT NULL DEFAULT FALSE, CONSTRAINT "CHK_agent_execution_storedAt" CHECK ((("storedAt" IN ('db', 'fs', 's3', 'az')))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK (((((("hitlStatus" IN ('suspended', 'resumed'))))))), CONSTRAINT "CHK_agent_execution_status" CHECK (("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted'))), 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')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "attachments" text, "failureSummary" text, "author" text, "acceptsSteering" BOOLEAN NOT NULL DEFAULT FALSE, "finishReason" VARCHAR(32), "guardrailCode" VARCHAR(64), CONSTRAINT "CHK_agent_execution_storedAt" CHECK ((("storedAt" IN ('db', 'fs', 's3', 'az')))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK (((((("hitlStatus" IN ('suspended', 'resumed'))))))), CONSTRAINT "CHK_agent_execution_status" CHECK (("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted'))), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) ``` @@ -24,6 +24,8 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId | duration | INTEGER | 0 | false | | | | | error | TEXT | | true | | | | | failureSummary | TEXT | | true | | | | +| finishReason | VARCHAR(32) | | true | | | | +| guardrailCode | VARCHAR(64) | | true | | | | | hitlStatus | varchar(16) | | true | | | | | id | varchar(36) | | false | [agent_background_job](agent_background_job.md) [agent_execution_message_links](agent_execution_message_links.md) [agent_message_queue](agent_message_queue.md) | | | | model | varchar(255) | | true | | | | @@ -79,6 +81,8 @@ erDiagram INTEGER duration TEXT error TEXT failureSummary + VARCHAR_32_ finishReason + VARCHAR_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id PK varchar_255_ model diff --git a/docs/generated/sqlite-schema/agent_execution_message_links.md b/docs/generated/sqlite-schema/agent_execution_message_links.md index 1c4b7e04013..688f3fbdf6a 100644 --- a/docs/generated/sqlite-schema/agent_execution_message_links.md +++ b/docs/generated/sqlite-schema/agent_execution_message_links.md @@ -65,6 +65,8 @@ erDiagram INTEGER duration TEXT error TEXT failureSummary + VARCHAR_32_ finishReason + VARCHAR_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id PK varchar_255_ model diff --git a/docs/generated/sqlite-schema/agent_execution_threads.md b/docs/generated/sqlite-schema/agent_execution_threads.md index 405eda5a1d1..b9650147c08 100644 --- a/docs/generated/sqlite-schema/agent_execution_threads.md +++ b/docs/generated/sqlite-schema/agent_execution_threads.md @@ -121,6 +121,8 @@ erDiagram INTEGER duration TEXT error TEXT failureSummary + VARCHAR_32_ finishReason + VARCHAR_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id PK varchar_255_ model diff --git a/docs/generated/sqlite-schema/agent_message_queue.md b/docs/generated/sqlite-schema/agent_message_queue.md index 31434931ce2..1f7982aa706 100644 --- a/docs/generated/sqlite-schema/agent_message_queue.md +++ b/docs/generated/sqlite-schema/agent_message_queue.md @@ -79,6 +79,8 @@ erDiagram INTEGER duration TEXT error TEXT failureSummary + VARCHAR_32_ finishReason + VARCHAR_64_ guardrailCode varchar_16_ hitlStatus varchar_36_ id PK varchar_255_ model diff --git a/packages/@n8n/db/src/migrations/common/1792597769231-AddGuardrailStopToAgentExecution.ts b/packages/@n8n/db/src/migrations/common/1792597769231-AddGuardrailStopToAgentExecution.ts new file mode 100644 index 00000000000..1a650535c74 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1792597769231-AddGuardrailStopToAgentExecution.ts @@ -0,0 +1,32 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class AddGuardrailStopToAgentExecution1792597769231 implements ReversibleMigration { + async up({ escape, runQuery, isPostgres }: MigrationContext) { + const agentExecution = escape.tableName('agent_execution'); + const finishReason = escape.columnName('finishReason'); + const guardrailCode = escape.columnName('guardrailCode'); + + // Plain ALTER keeps SQLite from recreating a potentially large table. + await runQuery(`ALTER TABLE ${agentExecution} ADD COLUMN ${finishReason} VARCHAR(32)`); + await runQuery(`ALTER TABLE ${agentExecution} ADD COLUMN ${guardrailCode} VARCHAR(64)`); + if (isPostgres) { + await runQuery( + `COMMENT ON COLUMN ${agentExecution}.${finishReason} IS 'Why the run ended, e.g. stop or guardrail. Null for older runs.'`, + ); + await runQuery( + `COMMENT ON COLUMN ${agentExecution}.${guardrailCode} IS 'Code of the guardrail that stopped the run, e.g. budget.session.'`, + ); + } + } + + async down({ escape, runQuery }: MigrationContext) { + const agentExecution = escape.tableName('agent_execution'); + + await runQuery( + `ALTER TABLE ${agentExecution} DROP COLUMN ${escape.columnName('guardrailCode')}`, + ); + await runQuery( + `ALTER TABLE ${agentExecution} DROP COLUMN ${escape.columnName('finishReason')}`, + ); + } +} 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 872bd2912ff..b08cb91ee08 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 @@ -86,6 +86,7 @@ function makeMessageRecord(overrides: Partial = {}): MessageRecor startTime: 0, duration: 1, error: null, + guardrailCode: null, ...overrides, }; } @@ -728,6 +729,25 @@ describe('AgentExecutionService', () => { }, ); + it('stores the finish reason and guardrail code on the execution row', async () => { + await service.finalizeExecution('execution-1', { + threadId: 'thread-1', + agentId: 'agent-1', + agentName: 'Agent', + projectId: 'project-1', + userMessage: null, + record: makeMessageRecord({ finishReason: 'guardrail', guardrailCode: 'budget.monthly' }), + }); + + expect(agentExecutionRepository.updateIfRunning).toHaveBeenCalledWith( + 'execution-1', + expect.objectContaining({ finishReason: 'guardrail', guardrailCode: 'budget.monthly' }), + undefined, + expect.any(Object), + undefined, + ); + }); + it('keeps terminal persistence successful when usage totals and model backfill fail', async () => { agentExecutionThreadRepository.incrementUsage.mockRejectedValue( new Error('usage update failed'), @@ -966,6 +986,7 @@ describe('AgentExecutionService', () => { startTime: Date.parse('2026-05-07T10:00:00Z'), duration: 1234, error: null, + guardrailCode: null, }; agentExecutionThreadRepository.findOrCreate.mockResolvedValue({ thread, created: true }); agentExecutionRepository.create.mockImplementation((entity) => entity as AgentExecution); 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 beed4ec2d0a..7b0264b7d6d 100644 --- a/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts +++ b/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts @@ -403,6 +403,32 @@ describe('ExecutionRecorder', () => { const record = recorder.getMessageRecord(); expect(record.assistantResponse).toBe('Hello world'); }); + + it('records a guardrail stop after the final text', () => { + const recorder = new ExecutionRecorder(); + + recorder.record({ type: 'text-delta', id: 't1', delta: 'Partial' }); + recorder.record({ + type: 'finish', + finishReason: 'guardrail', + guardrail: { code: 'budget.session' }, + }); + + const record = recorder.getMessageRecord(); + expect(record.guardrailCode).toBe('budget.session'); + expect(record.timeline.map((event) => event.type)).toEqual(['text', 'guardrail']); + expect(record.timeline[1]).toMatchObject({ type: 'guardrail', code: 'budget.session' }); + }); + + it('records no guardrail stop for a normal finish', () => { + const recorder = new ExecutionRecorder(); + + recorder.record({ type: 'finish', finishReason: 'stop' }); + + const record = recorder.getMessageRecord(); + expect(record.guardrailCode).toBeNull(); + expect(record.timeline).toEqual([]); + }); }); describe('secret scrubbing', () => { diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts index e0ebee88dec..345e066030d 100644 --- a/packages/cli/src/modules/agents/agent-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-execution.service.ts @@ -1072,6 +1072,8 @@ export class AgentExecutionService { error: record.error, failureSummary, hitlStatus: hitlStatus ?? null, + finishReason: record.finishReason, + guardrailCode: record.guardrailCode, }; const maxAttempts = 3; let lastError: unknown; diff --git a/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts b/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts index a7636003153..d33727d1b09 100644 --- a/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts +++ b/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts @@ -341,6 +341,18 @@ function buildEventRun(event: TimelineEvent, execution: AgentExecution, path: st return buildHitlResponseRun(event, path); case 'tool-call': return buildToolCallRun(event, path); + case 'guardrail': + return { + path, + name: 'Guardrail stop', + runType: 'chain', + startTime: event.timestamp, + endTime: event.timestamp, + inputs: { code: event.code }, + outputs: { status: 'stopped' }, + metadata: {}, + children: [], + }; } } 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 60adc628356..1c86dbeb996 100644 --- a/packages/cli/src/modules/agents/entities/agent-execution.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-execution.entity.ts @@ -99,6 +99,14 @@ export class AgentExecution extends WithTimestampsAndStringId { @Column({ type: 'varchar', length: 16, nullable: true }) hitlStatus: AgentExecutionHitlStatus | null; + /** Why the run ended, e.g. 'stop' or 'guardrail'. Null for runs recorded before this column. */ + @Column({ type: 'varchar', length: 32, nullable: true }) + finishReason: string | null; + + /** Code of the guardrail that stopped the run, e.g. 'budget.session'. */ + @Column({ type: 'varchar', length: 64, nullable: true }) + guardrailCode: string | null; + /** Where the run originated, e.g. 'chat', 'slack'. */ @Column({ type: 'varchar', length: 32, nullable: true }) source: string | null; diff --git a/packages/cli/src/modules/agents/execution-recorder.ts b/packages/cli/src/modules/agents/execution-recorder.ts index addc4235661..0c2f994d078 100644 --- a/packages/cli/src/modules/agents/execution-recorder.ts +++ b/packages/cli/src/modules/agents/execution-recorder.ts @@ -265,6 +265,11 @@ export type TimelineEvent = toolCallId: string; response: unknown; timestamp: number; + } + | { + type: 'guardrail'; + code: string; + timestamp: number; }; /** @@ -281,6 +286,7 @@ export interface MessageRecord { startTime: number; duration: number; error: string | null; + guardrailCode: string | null; } export class ExecutionRecorder { @@ -322,6 +328,8 @@ export class ExecutionRecorder { private finishReason = 'unknown'; + private guardrailCode: string | null = null; + private usage: RecordedUsage | null = null; private totalCost: number | null = null; @@ -436,6 +444,14 @@ export class ExecutionRecorder { case 'finish': this.flushReasoningBuffer(); this.flushTextBuffer(); + if (chunk.guardrail) { + this.guardrailCode = chunk.guardrail.code; + this.appendCompletedEvent({ + type: 'guardrail', + code: chunk.guardrail.code, + timestamp: Date.now(), + }); + } this.finishReason = chunk.finishReason; if (chunk.usage) { this.usage = { @@ -498,6 +514,7 @@ export class ExecutionRecorder { startTime: this.startTime, duration: Date.now() - this.startTime, error: this.error, + guardrailCode: this.guardrailCode, }; } 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 e7c439afc20..b0a9a0e8e84 100644 --- a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts @@ -21,7 +21,13 @@ type AgentExecutionFinalizationValues = Pick< Partial< Pick< AgentExecution, - 'model' | 'promptTokens' | 'completionTokens' | 'totalTokens' | 'hitlStatus' + | 'model' + | 'promptTokens' + | 'completionTokens' + | 'totalTokens' + | 'hitlStatus' + | 'finishReason' + | 'guardrailCode' > >; diff --git a/packages/cli/src/modules/agents/utils/__tests__/fatal-session-outcome.test.ts b/packages/cli/src/modules/agents/utils/__tests__/fatal-session-outcome.test.ts index 5ba2479f035..c673b35cd26 100644 --- a/packages/cli/src/modules/agents/utils/__tests__/fatal-session-outcome.test.ts +++ b/packages/cli/src/modules/agents/utils/__tests__/fatal-session-outcome.test.ts @@ -28,6 +28,7 @@ function record(overrides: Partial = {}): MessageRecord { startTime: 0, duration: 1, error: null, + guardrailCode: null, ...overrides, }; } diff --git a/packages/cli/src/modules/instance-ai/__tests__/format-agent-session-context.test.ts b/packages/cli/src/modules/instance-ai/__tests__/format-agent-session-context.test.ts index 0323a077b5d..799da34898d 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/format-agent-session-context.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/format-agent-session-context.test.ts @@ -231,6 +231,18 @@ describe('formatPreviewSessionContext', () => { expect(block).toContain('error=boom'); }); + it('renders a guardrail stop with its code instead of a tool call', () => { + const block = formatPreviewSessionContext(makeThread(), [ + makeExecution({ + timeline: [toolCallEvent(), { type: 'guardrail', code: 'budget.session', timestamp: 2000 }], + }), + ]); + + expect(block).toContain('[stopped by guardrail budget.session]'); + expect(block).toContain('Tool call: search_orders'); + expect(block?.match(/Tool call:/g)).toHaveLength(1); + }); + it('omits turns beyond the whole-block cap and reports the omission', () => { const bigText = 'y'.repeat(3_900); const executions = Array.from({ length: 40 }, (_, i) => diff --git a/packages/cli/src/modules/instance-ai/format-agent-session-context.ts b/packages/cli/src/modules/instance-ai/format-agent-session-context.ts index 335e5a95e9f..889029ccafc 100644 --- a/packages/cli/src/modules/instance-ai/format-agent-session-context.ts +++ b/packages/cli/src/modules/instance-ai/format-agent-session-context.ts @@ -69,6 +69,9 @@ function formatTimelineEvent(event: TimelineEvent, execution: AgentSessionTurn): if (event.type === 'hitl-response') { return `Human response: ${stringifyToolValue(event.response)}`; } + if (event.type === 'guardrail') { + return `[stopped by guardrail ${event.code}]`; + } const durationMs = event.endTime > 0 ? event.endTime - event.startTime : null; const headerParts = [ `Tool call: ${event.name}`, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 78c3a40941d..34ac5205ae5 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1646,6 +1646,7 @@ "agentSessions.deleteConfirm.message": "This will delete the session and all its executions. This cannot be undone.", "agentSessions.deleteConfirm.confirmButtonText": "Yes, delete", "agentSessions.showMessage.deleted": "Session deleted", + "agentSessions.showError.budget": "Problem increasing the budget cap", "agentSessions.showError.delete": "Problem deleting session", "agentSessions.showError.load": "Problem loading sessions", "agentSessions.status": "Status", @@ -1712,6 +1713,12 @@ "agentSessions.timeline.executionInterrupted": "Execution interrupted", "agentSessions.timeline.executionFailedFallback": "The agent execution failed before completing.", "agentSessions.timeline.executionInterruptedFallback": "The agent execution was interrupted before completing.", + "agentSessions.timeline.guardrailStop.label": "Run stopped", + "agentSessions.timeline.guardrailStop.session": "The session reached its cost cap.", + "agentSessions.timeline.guardrailStop.monthly": "The agent reached its monthly budget.", + "agentSessions.timeline.guardrailStop.credits": "Your Gateway credits balance is empty.", + "agentSessions.timeline.guardrailStop.freeCredits": "You have used all your free Gateway credits.", + "agentSessions.timeline.guardrailStop.fallback": "A guardrail stopped this run.", "agentSessions.timeline.toolError": "Tool call failed", "agentSessions.timeline.failed": "Failed", "agentSessions.timeline.filter": "Filter", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionTimelinePanel.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionTimelinePanel.spec.ts index 2cd4bdd3fbe..db7ea086043 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionTimelinePanel.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionTimelinePanel.spec.ts @@ -5,6 +5,7 @@ import { enableAutoUnmount, mount, flushPromises } from '@vue/test-utils'; import { reactive } from 'vue'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; import AgentSessionTimelinePanel from '../components/AgentSessionTimelinePanel.vue'; +import AgentBudgetNoticeCard from '../components/AgentBudgetNoticeCard.vue'; import type { ThreadDetail } from '../composables/useAgentThreadsApi'; import type { FilterOption } from '../session-timeline.types'; @@ -114,6 +115,8 @@ const keyboardExecution = { failureSummary: null, hitlStatus: null, source: null, + finishReason: null, + guardrailCode: null, } satisfies ThreadDetail['executions'][number]; const errorFilterExecution = { @@ -136,6 +139,16 @@ const errorFilterExecution = { ], } satisfies ThreadDetail['executions'][number]; +function budgetStopExecution(code: string, id = 'execution-2') { + return { + ...keyboardExecution, + id, + finishReason: 'guardrail', + guardrailCode: code, + timeline: [{ type: 'guardrail', code, timestamp: Date.parse('2026-08-03T12:00:02.000Z') }], + } satisfies ThreadDetail['executions'][number]; +} + function dispatchKeyboardEvent(type: 'keydown' | 'keyup', key: string) { const event = new KeyboardEvent(type, { key, bubbles: true, cancelable: true }); document.dispatchEvent(event); @@ -155,7 +168,7 @@ const update: PushMessage = { enableAutoUnmount(afterEach); function mountPanel( - props: Partial<{ projectId: string; agentId: string; threadId: string }> = {}, + props: Partial['$props']> = {}, options: { attachTo?: HTMLElement; renderTimelineTable?: boolean } = {}, ) { return mount(AgentSessionTimelinePanel, { @@ -506,4 +519,90 @@ describe('AgentSessionTimelinePanel', () => { item_type: 'agent', }); }); + describe('budget notice', () => { + const budgetCard = '[data-testid="agent-budget-notice-card"]'; + + async function mountWithExecutions( + executions: ThreadDetail['executions'], + props: Partial['$props']> = {}, + ) { + getThreadDetail.mockResolvedValue({ ...detail, executions }); + const wrapper = mountPanel(props, { renderTimelineTable: true }); + await flushPromises(); + return wrapper; + } + + it('renders the card directly below a budget stop in the latest run', async () => { + const wrapper = await mountWithExecutions([ + keyboardExecution, + budgetStopExecution('budget.session'), + ]); + + const noticeRow = wrapper.get('[data-test-id="timeline-notice-row"]'); + const stopRow = noticeRow.element.previousElementSibling; + expect(stopRow?.getAttribute('data-test-id')).toBe('timeline-row'); + expect(stopRow?.textContent).toContain('The session reached its cost cap.'); + expect(noticeRow.find(budgetCard).exists()).toBe(true); + }); + + it('does not render the card for a stop without budget copy', async () => { + const wrapper = await mountWithExecutions([budgetStopExecution('budget.credits')]); + + expect(wrapper.find(budgetCard).exists()).toBe(false); + }); + + it('does not render the card when a later run got past the stop', async () => { + const wrapper = await mountWithExecutions([ + budgetStopExecution('budget.monthly', 'execution-0'), + keyboardExecution, + ]); + + expect(wrapper.find(budgetCard).exists()).toBe(false); + }); + + it('hides the increase action when the user cannot raise the cap', async () => { + const wrapper = await mountWithExecutions([budgetStopExecution('budget.session')]); + + expect(wrapper.find(budgetCard).exists()).toBe(true); + expect(wrapper.find('[data-testid="agent-budget-notice-increase"]').exists()).toBe(false); + }); + + it.each([ + [true, false], + [false, true], + ])('when the save resolves %s, the card stays: %s', async (saved, cardStays) => { + const increaseBudget = vi.fn().mockResolvedValue(saved); + const wrapper = await mountWithExecutions([budgetStopExecution('budget.session')], { + increaseBudget, + }); + + const payload = { field: 'sessionCostCapUsd', amount: 20 }; + wrapper.getComponent(AgentBudgetNoticeCard).vm.$emit('increase', payload); + await flushPromises(); + + expect(increaseBudget).toHaveBeenCalledWith(payload); + expect(wrapper.find(budgetCard).exists()).toBe(cardStays); + }); + + it('shows the card again when a newer run stops at the raised cap', async () => { + const increaseBudget = vi.fn().mockResolvedValue(true); + const firstStop = budgetStopExecution('budget.session', 'execution-2'); + const wrapper = await mountWithExecutions([firstStop], { increaseBudget }); + + wrapper + .getComponent(AgentBudgetNoticeCard) + .vm.$emit('increase', { field: 'sessionCostCapUsd', amount: 20 }); + await flushPromises(); + expect(wrapper.find(budgetCard).exists()).toBe(false); + + getThreadDetail.mockResolvedValue({ + ...detail, + executions: [firstStop, budgetStopExecution('budget.session', 'execution-3')], + }); + emitPush(); + await flushPromises(); + + expect(wrapper.find(budgetCard).exists()).toBe(true); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineTable.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineTable.spec.ts index 0b9948088a4..23afd14b6d6 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineTable.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineTable.spec.ts @@ -386,4 +386,30 @@ describe('SessionTimelineTable', () => { expect(w.emitted('select')).toBeUndefined(); }); + describe('notice slot', () => { + function mountWithNotice(visibleKinds = new Set()) { + return mount(SessionTimelineTable, { + props: { items: makeItems(), selectedIndex: null, visibleKinds, noticeIndex: 2 }, + slots: { notice: '
' }, + global: { plugins: [makeRouter()] }, + }); + } + + it('renders one notice row directly below the matching event row', () => { + const w = mountWithNotice(); + + const noticeRows = w.findAll('[data-test-id="timeline-notice-row"]'); + expect(noticeRows).toHaveLength(1); + expect( + noticeRows[0].element.previousElementSibling?.getAttribute('data-timeline-row-id'), + ).toBe('event-2'); + expect(noticeRows[0].find('[data-test-id="notice-content"]').exists()).toBe(true); + }); + + it('omits the notice when its event row is filtered out', () => { + const w = mountWithNotice(new Set(['workflow'])); + + expect(w.find('[data-test-id="timeline-notice-row"]').exists()).toBe(false); + }); + }); }); 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 2315328274f..d099ad74fe8 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 @@ -14,6 +14,8 @@ import { isErroredToolCallTimelineItem, matchesTimelineFilters, timelineItemErrorMessage, + executionErrorLabel, + executionErrorMessage, } from '../session-timeline.utils'; import type { TimelineItem } from '../session-timeline.types'; @@ -242,6 +244,8 @@ function exec(overrides: Partial = {}): AgentExecution { failureSummary: null, hitlStatus: null, source: null, + finishReason: null, + guardrailCode: null, ...overrides, }; } @@ -457,6 +461,24 @@ describe('flattenExecutionsToTimelineItems', () => { ]); }); + it('adds a stop item at the guardrail event', () => { + const items = flattenExecutionsToTimelineItems([ + withTimeline([{ type: 'guardrail', code: 'budget.session', timestamp: 500 }], { + finishReason: 'guardrail', + guardrailCode: 'budget.session', + }), + ]); + + expect(items).toEqual([ + { + kind: 'execution-error', + executionId: 'e-1', + guardrailCode: 'budget.session', + timestamp: 500, + }, + ]); + }); + it.each(['success', 'cancelled'] as const)( 'does not add a synthetic item for a %s execution', (status) => { @@ -1207,3 +1229,20 @@ describe('background task signals', () => { expect(kindColorToken(event.kind)).toBeDefined(); }); }); + +describe('guardrail stop copy', () => { + const i18n = { baseText: (key: string) => key } as Parameters[1]; + + it.each([ + ['budget.session', 'agentSessions.timeline.guardrailStop.session'], + ['budget.monthly', 'agentSessions.timeline.guardrailStop.monthly'], + ['budget.credits', 'agentSessions.timeline.guardrailStop.credits'], + ['budget.freeCredits', 'agentSessions.timeline.guardrailStop.freeCredits'], + ['budget.misconfigured', 'agentSessions.timeline.guardrailStop.fallback'], + ])('labels a %s stop', (guardrailCode, messageKey) => { + const stop = item({ kind: 'execution-error', guardrailCode }); + + expect(executionErrorLabel(stop, i18n)).toBe('agentSessions.timeline.guardrailStop.label'); + expect(executionErrorMessage(stop, i18n)).toBe(messageKey); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue index fd420ea6b65..76670a8933e 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue @@ -11,6 +11,8 @@ import SessionTimelineChart from '@/features/agents/components/SessionTimelineCh import SessionEventFilter from '@/features/agents/components/SessionEventFilter.vue'; import SessionTimelineTable from '@/features/agents/components/SessionTimelineTable.vue'; import SessionDetailPanel from '@/features/agents/components/SessionDetailPanel.vue'; +import AgentBudgetNoticeCard from '@/features/agents/components/AgentBudgetNoticeCard.vue'; +import { isBudgetStopCode, type BudgetAmountField } from '@/features/agents/utils/budget-config'; import { flattenExecutionsToTimelineItems, computeIdleRanges, @@ -40,6 +42,11 @@ const props = defineProps<{ projectId: string; agentId: string; threadId: string; + /** + * Persists a raised budget cap. Omitted when the user cannot edit the + * agent, which hides the increase action on the budget notice. + */ + increaseBudget?: (payload: { field: BudgetAmountField; amount: number }) => Promise; }>(); // Hands the loaded thread detail up so an enclosing view (the standalone @@ -66,6 +73,8 @@ const selectedIndex = ref(null); const highlightedIndex = ref(null); const selectedFilters = ref>(new Set()); const searchQuery = ref(''); +const resolvedBudgetStopExecutionId = ref(null); +const budgetIncreasePending = ref(false); let threadDetailRequestId = 0; let refreshPending = false; let removePushListener: (() => void) | undefined; @@ -90,8 +99,46 @@ const items = computed(() => }), ); const idleRanges = computed(() => computeIdleRanges(items.value)); + const bounds = computed(() => sessionBounds(items.value)); +// Only a budget stop in the latest run is still open: a later run got past an +// older one. Raising the cap here resolves that run's stop, but the refreshed +// history keeps the stop item, so remember which run was resolved. A newer run +// that stops at the raised cap gets its own notice. +const budgetStop = computed<{ + index: number; + executionId: string; + code: 'budget.monthly' | 'budget.session'; +} | null>(() => { + const lastExecutionId = executions.value.at(-1)?.id; + if (!lastExecutionId || lastExecutionId === resolvedBudgetStopExecutionId.value) return null; + for (let index = items.value.length - 1; index >= 0; index--) { + const item = items.value[index]; + if (item.executionId !== lastExecutionId || item.kind !== 'execution-error') continue; + if (item.guardrailCode && isBudgetStopCode(item.guardrailCode)) { + return { index, executionId: lastExecutionId, code: item.guardrailCode }; + } + } + return null; +}); + +async function onIncreaseBudget(payload: { field: BudgetAmountField; amount: number }) { + const stopExecutionId = budgetStop.value?.executionId; + if (!props.increaseBudget || !stopExecutionId || budgetIncreasePending.value) return; + // The save outlives a thread switch: only close the notice it was raised from. + const identity = threadIdentity(); + budgetIncreasePending.value = true; + try { + const saved = await props.increaseBudget(payload); + if (saved && identity === threadIdentity()) { + resolvedBudgetStopExecutionId.value = stopExecutionId; + } + } finally { + budgetIncreasePending.value = false; + } +} + function labelForKey(key: string): string { const backgroundJobKey = backgroundJobTimelineLabelKey(key); if (backgroundJobKey) return i18n.baseText(backgroundJobKey); @@ -280,6 +327,7 @@ function loadThreadDetail() { executions.value = []; selectedFilters.value = new Set(); searchQuery.value = ''; + resolvedBudgetStopExecutionId.value = null; selectTimelineItem(null); loading.value = true; emit('loaded', null); @@ -429,8 +477,19 @@ watch([() => props.projectId, () => props.agentId, () => props.threadId], loadTh :selected-index="highlightedIndex" :visible-kinds="selectedFilters" :search-query="searchQuery" + :notice-index="budgetStop?.index ?? null" @select="onTimelineItemSelect" - /> + > + +
diff --git a/packages/frontend/editor-ui/src/features/agents/components/SessionTimelineTable.vue b/packages/frontend/editor-ui/src/features/agents/components/SessionTimelineTable.vue index 76c99ef7c3f..48f6be15a5e 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/SessionTimelineTable.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/SessionTimelineTable.vue @@ -17,8 +17,12 @@ const props = defineProps<{ visibleKinds: Set; searchQuery?: string; idleRanges?: IdleRange[]; + /** Index of the item that gets the `notice` slot rendered directly below it. */ + noticeIndex?: number | null; }>(); +defineSlots<{ notice?: (props: { item: TimelineItem }) => unknown }>(); + const emit = defineEmits<{ select: [index: number] }>(); const i18n = useI18n(); @@ -73,7 +77,8 @@ function labelForKey(key: string): string { type Row = | { id: string; kind: 'event'; item: TimelineItem; index: number; sortKey: number } - | { id: string; kind: 'idle'; range: IdleRange; sortKey: number }; + | { id: string; kind: 'idle'; range: IdleRange; sortKey: number } + | { id: string; kind: 'notice'; item: TimelineItem; sortKey: number }; const rows = computed(() => { const events: Row[] = filteredTimelineItemIndexes( @@ -96,7 +101,21 @@ const rows = computed(() => { sortKey: range.start, })); - return [...events, ...idles].sort((a, b) => a.sortKey - b.sortKey); + const sorted = [...events, ...idles].sort((a, b) => a.sortKey - b.sortKey); + + // Spliced in after sorting so the notice stays directly below its event. + const noticeIndex = props.noticeIndex; + if (noticeIndex === null || noticeIndex === undefined) return sorted; + const position = sorted.findIndex((row) => row.kind === 'event' && row.index === noticeIndex); + if (position === -1) return sorted; + const anchor = sorted[position]; + sorted.splice(position + 1, 0, { + id: `notice-${noticeIndex}`, + kind: 'notice', + item: props.items[noticeIndex], + sortKey: anchor.sortKey, + }); + return sorted; }); const shouldVirtualizeRows = computed(() => rows.value.length > VIRTUALIZE_AFTER_ROWS); @@ -260,6 +279,17 @@ watch( >
+
+
+ +
+
+
+
+ +
+
> = { + 'budget.session': 'agentSessions.timeline.guardrailStop.session', + 'budget.monthly': 'agentSessions.timeline.guardrailStop.monthly', + 'budget.credits': 'agentSessions.timeline.guardrailStop.credits', + 'budget.freeCredits': 'agentSessions.timeline.guardrailStop.freeCredits', +}; + export function endTimestampOf(item: TimelineItem): number { return item.endTimestamp ?? item.timestamp; } @@ -156,6 +163,7 @@ export function backgroundJobSignalSummary( } export function executionErrorLabel(item: TimelineItem, i18n: TimelineI18n): string { + if (item.guardrailCode) return i18n.baseText('agentSessions.timeline.guardrailStop.label'); return i18n.baseText( item.executionStatus === 'interrupted' ? 'agentSessions.timeline.executionInterrupted' @@ -164,6 +172,12 @@ export function executionErrorLabel(item: TimelineItem, i18n: TimelineI18n): str } export function executionErrorMessage(item: TimelineItem, i18n: TimelineI18n): string { + if (item.guardrailCode) { + return i18n.baseText( + GUARDRAIL_STOP_MESSAGE_KEYS[item.guardrailCode] ?? + 'agentSessions.timeline.guardrailStop.fallback', + ); + } if (item.content) return item.content; return i18n.baseText( item.executionStatus === 'interrupted' @@ -476,6 +490,12 @@ interface RawBackgroundJobSignalEvent { signal: AgentBackgroundJobSignal; } +interface GuardrailEvent { + type: 'guardrail'; + timestamp: number; + code: string; +} + type RawEvent = | RawToolCallEvent | RawTextEvent @@ -483,7 +503,8 @@ type RawEvent = | RawInputEvent | RawSuspensionEvent | RawHitlResponseEvent - | RawBackgroundJobSignalEvent; + | RawBackgroundJobSignalEvent + | GuardrailEvent; /** * Cast the loose API timeline shape (`Record & { type }`) @@ -844,6 +865,13 @@ export function flattenExecutionsToTimelineItems(executions: AgentExecution[]): if (!hitlContext) continue; hitlContext.hasExplicitResponse = true; items.push(hitlResponseItem(hitlContext, exec.id, event.response, event.timestamp ?? 0)); + } else if (event.type === 'guardrail') { + items.push({ + kind: 'execution-error', + executionId: exec.id, + guardrailCode: event.code, + timestamp: event.timestamp ?? 0, + }); } } flushThinking(); diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue index 59d51e86566..be9f8d76d3b 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue @@ -27,6 +27,11 @@ import { useAgentConfig } from '@/features/agents/composables/useAgentConfig'; import { useAgentPermissions } from '@/features/agents/composables/useAgentPermissions'; import { useBackOrFallback } from '@/features/agents/composables/useBackOrFallback'; import type { AgentResource } from '@/features/agents/types'; +import { + increasedBudgetConfig, + type BudgetAmountField, +} from '@/features/agents/utils/budget-config'; +import { useToast } from '@n8n/composables/useToast'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useI18n } from '@n8n/i18n'; import { N8nEmptyState } from '@n8n/design-system'; @@ -45,7 +50,8 @@ const { sendSession, } = useAgentSessionLangSmithExport(); const rootStore = useRootStore(); -const { config: localConfig, fetchConfig } = useAgentConfig(); +const toast = useToast(); +const { config: localConfig, fetchConfig, updateConfig } = useAgentConfig(); const projectId = computed(() => route.params.projectId as string); const agentId = computed(() => route.params.agentId as string); @@ -337,6 +343,29 @@ async function onDeletePreviewSession(sessionId: string) { void router.replace(agentExecutionsRoute.value); } +/** + * Raises a budget cap from the timeline's budget notice. Only a persisted cap + * counts: a stale or failed save would stop the next run against the old cap. + */ +async function onIncreaseBudget(payload: { + field: BudgetAmountField; + amount: number; +}): Promise { + const current = localConfig.value; + if (!canUpdate.value || !current) return false; + const update = increasedBudgetConfig(current, payload.field, payload.amount); + if (!update) return false; + try { + const result = await updateConfig(projectId.value, agentId.value, { ...current, ...update }); + if (result.stale) return false; + localConfig.value = result.config; + return true; + } catch (error) { + toast.showError(error, i18n.baseText('agentSessions.showError.budget')); + return false; + } +} + function togglePreview() { if (!canPreviewSession.value) return; isPreviewOpen.value = !isPreviewOpen.value; @@ -379,6 +408,7 @@ function viewPreviewTrace() { :project-id="projectId" :agent-id="agentId" :thread-id="threadId" + :increase-budget="canUpdate ? onIncreaseBudget : undefined" @loaded="onPanelLoaded" />