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"
/>