feat(core): Show budget guardrail stops in the agent session timeline (#41692)

This commit is contained in:
Anne Aguirre
2026-10-22 09:02:15 +00:00
committed by GitHub
parent e48fdfaa70
commit fecfbe52c5
33 changed files with 518 additions and 10 deletions
+3 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+3 -1
View File
@@ -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
+2
View File
@@ -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
+5 -1
View File
@@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```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)
```
</details>
@@ -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
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
@@ -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')}`,
);
}
}
@@ -86,6 +86,7 @@ function makeMessageRecord(overrides: Partial<MessageRecord> = {}): 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);
@@ -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', () => {
@@ -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;
@@ -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: [],
};
}
}
@@ -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;
@@ -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,
};
}
@@ -21,7 +21,13 @@ type AgentExecutionFinalizationValues = Pick<
Partial<
Pick<
AgentExecution,
'model' | 'promptTokens' | 'completionTokens' | 'totalTokens' | 'hitlStatus'
| 'model'
| 'promptTokens'
| 'completionTokens'
| 'totalTokens'
| 'hitlStatus'
| 'finishReason'
| 'guardrailCode'
>
>;
@@ -28,6 +28,7 @@ function record(overrides: Partial<MessageRecord> = {}): MessageRecord {
startTime: 0,
duration: 1,
error: null,
guardrailCode: null,
...overrides,
};
}
@@ -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) =>
@@ -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}`,
@@ -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",
@@ -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<InstanceType<typeof AgentSessionTimelinePanel>['$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<InstanceType<typeof AgentSessionTimelinePanel>['$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);
});
});
});
@@ -386,4 +386,30 @@ describe('SessionTimelineTable', () => {
expect(w.emitted('select')).toBeUndefined();
});
describe('notice slot', () => {
function mountWithNotice(visibleKinds = new Set<string>()) {
return mount(SessionTimelineTable, {
props: { items: makeItems(), selectedIndex: null, visibleKinds, noticeIndex: 2 },
slots: { notice: '<div data-test-id="notice-content" />' },
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<string>(['workflow']));
expect(w.find('[data-test-id="timeline-notice-row"]').exists()).toBe(false);
});
});
});
@@ -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> = {}): 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<typeof executionErrorLabel>[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);
});
});
@@ -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<boolean>;
}>();
// Hands the loaded thread detail up so an enclosing view (the standalone
@@ -66,6 +73,8 @@ const selectedIndex = ref<number | null>(null);
const highlightedIndex = ref<number | null>(null);
const selectedFilters = ref<Set<string>>(new Set());
const searchQuery = ref('');
const resolvedBudgetStopExecutionId = ref<string | null>(null);
const budgetIncreasePending = ref(false);
let threadDetailRequestId = 0;
let refreshPending = false;
let removePushListener: (() => void) | undefined;
@@ -90,8 +99,46 @@ const items = computed<TimelineItem[]>(() =>
}),
);
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"
/>
>
<template #notice>
<AgentBudgetNoticeCard
v-if="budgetStop"
:code="budgetStop.code"
:can-increase="props.increaseBudget !== undefined"
:pending="budgetIncreasePending"
@increase="onIncreaseBudget"
/>
</template>
</SessionTimelineTable>
</div>
<Transition name="session-detail-panel">
<div v-if="selectedItem" :class="$style.detailPanel">
@@ -17,8 +17,12 @@ const props = defineProps<{
visibleKinds: Set<string>;
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<Row[]>(() => {
const events: Row[] = filteredTimelineItemIndexes(
@@ -96,7 +101,21 @@ const rows = computed<Row[]>(() => {
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(
>
<SessionTimelineRow :item="row.item" :selected="props.selectedIndex === row.index" />
</div>
<div
v-else-if="row.kind === 'notice'"
data-test-id="timeline-notice-row"
:data-timeline-row-id="row.id"
:class="$style.noticeRow"
role="row"
>
<div :class="$style.noticeCell" role="gridcell">
<slot name="notice" :item="row.item" />
</div>
</div>
<div
v-else
data-test-id="timeline-idle-row"
@@ -296,6 +326,17 @@ watch(
>
<SessionTimelineRow :item="row.item" :selected="props.selectedIndex === row.index" />
</div>
<div
v-else-if="row.kind === 'notice'"
data-test-id="timeline-notice-row"
:data-timeline-row-id="row.id"
:class="$style.noticeRow"
role="row"
>
<div :class="$style.noticeCell" role="gridcell">
<slot name="notice" :item="row.item" />
</div>
</div>
<div
v-else
data-test-id="timeline-idle-row"
@@ -380,6 +421,14 @@ watch(
mask-image: linear-gradient(to bottom, transparent 0%, black 2%, black 95%, transparent 100%);
}
.noticeRow {
padding: var(--spacing--2xs) var(--spacing--sm) var(--spacing--sm);
}
.noticeCell {
display: flex;
justify-content: center;
width: 100%;
}
.idleRow {
position: relative;
display: flex;
@@ -106,6 +106,8 @@ export interface AgentExecution {
failureSummary: AgentExecutionFailureSummary | null;
hitlStatus: AgentExecutionHitlStatus | null;
source: string | null;
finishReason: string | null;
guardrailCode: string | null;
}
export interface ThreadDetail {
@@ -73,6 +73,7 @@ export interface TimelineItem {
*/
subAgentName?: string;
resumed?: boolean;
guardrailCode?: string;
}
export interface IdleRange {
@@ -30,6 +30,13 @@ export const IDLE_THRESHOLD_MS = 10 * 60 * 1000;
const LOAD_SKILL_TOOL_NAME = 'load_skill';
const GUARDRAIL_STOP_MESSAGE_KEYS: Partial<Record<string, BaseTextKey>> = {
'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<string, unknown> & { 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();
@@ -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<boolean> {
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"
/>
<div v-else :class="$style.newSessionEmpty">