Ця стаття — коротка. Вона не пояснює архітектуру, не будує наратив. Це довідник для розробника, який будує AI-агента на n8n і хоче не витратити 4-6 годин на кожен з цих багів (як витратив я).

Всі п'ять — з реального debugging journey puramur sales-агента: 39 нодів, semantic router, state machine з 8 стадій, LOAD → PROCESS → SAVE цикли з Postgres на кожному повідомленні. Симптоми, репродукції і фікси перевірені у продакшн workflow.

Мета: пробігтись по 5 карткам за 15 хвилин, зафіксувати паттерни у пам'яті, і при наступному дебагу впізнати їх за 2 хвилини замість 2 годин.

Bug #1 — AI Agent не пропускає input далі

Bug 01

AI Agent output не містить попередній input

Symptom

Ти передав state у AI Agent, він відпрацював, генерував відповідь. У наступній ноді (Extract State Update code) звертаєшся до $json.state.stage — undefined. У $json тільки поле output (текст LLM) і нічого крім нього.

Repro

Load Session State → Build Prompt → AI Agent → Extract State Update.
У Extract State Update пишеш:

const state = $json.state; // undefined const customer = $json.state.customer; // TypeError const llmOutput = $json.output; // works
Cause

AI Agent node у n8n не є transparent proxy. Він не передає input.json далі — його output це тільки те що згенерував LLM (+ intermediate steps і tool results, залежно від конфігу). Всі попередні поля з input pipeline «зникають» на цій ноді.

Це поведінка «by design» але контрінтуїтивна — більшість інших нод (Code, Set, HTTP Request) або пропускають input далі, або мають опції на це.

Fix

Звертайся до попередніх нод по імені, а не через $json. У n8n синтаксис: $('Node Name').first().json.

const state = $('Route Decision').first().json.state; const customer = state.customer; const llmOutput = $json.output; // AI Agent output

Це працює на будь-якому вузлі pipeline незалежно від того що між ними. n8n тримає результати кожної ноди у виконанні workflow і $(...) — це прямий доступ до них.

Принцип

У n8n workflow з AI Agent — ніколи не покладайся на $json для state. Завжди використовуй $('SpecificNode').first().json. Це trivial refactor який рятує від класу помилок, а іменована залежність — легше читається у review.

Bug #2 — Postgres executeQuery підміняє input

Bug 02

Після Postgres executeQuery оригінальний input недоступний як $json

Symptom

Ти зробив Postgres node з операцією Execute Query (наприклад SELECT з бази знань). Наступна нода — Code, яка має використати і SQL результат, і дані з попередніх нод. $json містить тільки колонки SELECT'а. Дані до Postgres — не видно.

Repro

Detect Language (виставляє session_id, chat_id) → Postgres executeQuery SELECT * FROM kb_faq WHERE ... → Code node:

const faqRows = $input.all(); // rows з SQL const chatId = $json.chat_id; // undefined! const sessionId = $json.session_id; // undefined!
Cause

Postgres node у executeQuery mode заміщає input результатом SELECT. Кожен рядок з SQL стає окремим item у output. Оригінальні поля з попередніх нод не мержаться. Це відрізняється від Insert/Update operations, де можна включити return.

Плутанина посилюється тим що для деяких Postgres операцій (Insert with returnAll) поведінка інша — там input може частково пройти.

Fix

Той самий паттерн що з AI Agent — звертайся до попередньої ноди по імені:

const faqRows = $input.all(); const chatId = $('Detect Language').first().json.chat_id; const sessionId = $('Detect Language').first().json.session_id;

Це має ще одну перевагу — робить залежності явними. Якщо хтось перейменує Detect Language, workflow одразу впаде з ясним повідомленням, а не тихо працюватиме з undefined.

Bug #3 — Load node зависає на порожньому SELECT

Bug 03

Workflow обривається коли SELECT повертає 0 rows

Symptom

Load Session State для нової сесії (session_id ще не в БД). SELECT повертає 0 rows. Наступні ноди не запускаються. Workflow виглядає «застряглим». У executions dashboard — status Waiting або обірваний run без помилки.

Repro
// Load Session State (Postgres executeQuery) SELECT * FROM puramur_sessions_state WHERE session_id = $1; // Query Parameters ={{ [$('Detect Language').first().json.session_id] }}

Для нового користувача цей SELECT повертає 0 rows. Наступний Code node «Initialize State» не запускається взагалі. Немає error, немає output, workflow просто зупиняється на Load Session State.

Cause

За замовчуванням n8n нода з zero items output обриває pipeline. Логіка «якщо немає результату — немає що передавати наступному». Це прийнятно для більшості випадків, але руйнує LOAD-паттерн для стану — саме коли нова сесія (немає рядка) треба створити default state, а не обірватись.

Fix

У Postgres ноди відкрий вкладку Settings (не Parameters!) і увімкни Always Output Data. Тепер при 0 rows нода віддасть один порожній item, далі — твій Init State code перевіряє це і будує default state.

// Initialize State (Code node, runOnceForAllItems) const upstream = $('Detect Language').first().json; const rows = $input.all(); let state; if (rows.length === 0 || !rows[0].json.session_id) { // Нова сесія — default state state = { session_id: upstream.session_id, stage: 'greeting', stage_iteration: 0, customer: {}, pet: {}, cart: [], is_new_session: true, }; } else { // Existing session — parse const row = rows[0].json; state = { ...row, is_new_session: false }; } return [{ json: { ...upstream, state }}];
Де ще застосовується

Always Output Data потрібен на будь-якій Postgres/HTTP/Split ноді яка може легально повернути 0 items. Правило: якщо нода читає зовнішні дані і 0 items — це valid business case, увімкни toggle.

Bug #4 — Merge combineByPosition зависає на порожній гілці

Bug 04

Merge не запускається якщо один вхід порожній

Symptom

Ти маєш workflow з IF-нодою що розгалужує на два шляхи. Обидва шляхи мерджаться назад через Merge (mode: combineByPosition). Коли IF йде в одну гілку — інша порожня — Merge не спрацьовує. Workflow виглядає «залипшим» на Merge ноді.

Repro
Route Decision (Code) ↓ Switch by Route (5 outputs: pricing / faq / handoff / off_topic / sales) ↓ ↓ Pricing branch (Postgres tool) Sales branch (RAG) ↓ ↓ Merge (combineByPosition) ← висить ↓ Build Prompt

Коли Route Decision класифікує запит як pricing, тільки pricing branch виконується. Sales branch віддає 0 items. Merge combineByPosition чекає items на обох входах — і зависає.

Cause

combineByPosition об'єднує items по індексу — item[0] лівого входу + item[0] правого входу → merged item[0]. Якщо один вхід має 0 items, для індексу 0 немає пари. Нода або зависає, або дає порожній output, або кидає internal error залежно від версії n8n.

Це фундаментальна невідповідність між ментальною моделлю розробника («мержу гілки») і фактичною роботою mode («роблю декартовий join по позиції»).

Fix

Використовуй Merge mode append замість combineByPosition. Append просто конкатенує items з обох входів. Порожня гілка — 0 items додається. Не-порожня — свої items. Далі Build Prompt code читає що прийшло і формує промпт.

// Merge node config { "mode": "append" // не "combineByPosition" } // Build Prompt (Code) — обробляє один або кілька items const items = $input.all(); const route = $('Route Decision').first().json.route; let context; if (route === 'pricing') { // items тут — pricing rows context = formatPricingContext(items.map(i => i.json)); } else if (route === 'sales') { // items — RAG chunks context = formatRagContext(items.map(i => i.json)); }

Альтернатива — робити окремі AI Agent ноди для кожної гілки і з'єднувати без Merge взагалі, використовуючи Switch → окремий AI Agent per route → окремий Extract → загальний Save. Але це розмножує кількість нодів у workflow, тому append + гілко-aware Build Prompt зазвичай кращий.

Bug #5 — queryReplacement з $json ламається після Postgres

Bug 05

Postgres SAVE node ламається з cryptic type errors

Symptom

SAVE Session State (Postgres INSERT/UPSERT) кидає помилку типу:

error: column "customer" is of type jsonb but expression is of type text error: invalid input syntax for type integer: "undefined" error: null value in column "session_id" violates not-null constraint

Це трапляється тільки коли перед SAVE стоїть інша Postgres нода (наприклад SELECT для верифікації). Якщо тестуєш ізольовано з чистими даними — все працює.

Repro
// SAVE Postgres node, executeQuery mode INSERT INTO puramur_sessions_state ( session_id, stage, customer, pet, cart_total ) VALUES ($1, $2, $3::jsonb, $4::jsonb, $5::numeric); // Query Parameters (проблемно) ={{ [ $json.session_id, $json.stage, JSON.stringify($json.customer), JSON.stringify($json.pet), $json.cart_total ] }}

Тут $json вказує на попередню Postgres ноду (SELECT). Але поля session_id, stage, customer з state (згенерованого Extract State code), а не з SELECT результату.

Cause

Три речі накладаються одна на одну:

  1. $json у Query Parameters посилається на прямий попередник у pipeline. Якщо це Postgres нода — $json = row з SELECT, а не state з Extract.
  2. Якщо поле відсутнє (SELECT не містить session_id) — $json.session_id = undefined. n8n серіалізує це у string "undefined", Postgres кидає type error.
  3. Для JSONB полів потрібен явний JSON.stringify. Без нього n8n передає JavaScript object як string "[object Object]", Postgres не парсить це як JSONB.
Fix

Три правила для Query Parameters у Postgres save-нодах:

={{ [ // (1) Явні посилання на ноди з даними $('Extract State Update').first().json.state.session_id, $('Extract State Update').first().json.state.stage, // (2) JSON.stringify для JSONB полів JSON.stringify($('Extract State Update').first().json.state.customer || {}), JSON.stringify($('Extract State Update').first().json.state.pet || {}), // (3) Number/Boolean casts для числових/boolean полів Number($('Extract State Update').first().json.state.cart_total || 0), Boolean($('Extract State Update').first().json.state.cart_gift_eligible) ] }}

Плюс — у SQL використовуй явні cast: $1::text, $3::jsonb, $5::numeric. Це подвійна страховка: якщо JavaScript-сторона передає щось не те, cast зловить це з ясною помилкою «cannot cast X to Y», а не тихо запише stringified junk.

Об'єднана ментальна модель

Ці п'ять багів здаються різними, але у них спільна першопричина: ноди у n8n workflow не завжди пропускають input прозоро. AI Agent і Postgres executeQuery активно замінюють input своїм output. Merge combineByPosition не терпить асиметрії. Postgres не запускається на 0 items. Query Parameters лінкуються на прямий попередник, а не на «останнє релевантне джерело даних».

Ментальна модель яку я виробив після цього debugging journey:

  1. Не покладайся на $json. Завжди звертайся до конкретних нод через $('NodeName').first().json.field. Це trivial refactor який рятує від класу помилок.
  2. Читає з БД → Settings → Always Output Data. Пусті результати — це legitimate business state, не error.
  3. Merge = append, не combineByPosition. Якщо потрібен справжній JOIN по позиції, знай що робиш. У 95% AI workflow достатньо append.
  4. Postgres save = явні refs + typecasts. У Query Parameters: $('...').first().json.state.field. У SQL: $1::jsonb. Без цього ти пишеш «work by luck».
  5. Тестуй кожну ноду з edge cases. 0 rows, undefined fields, порожній branch у Switch. Успішний happy-path тест не покриває ці баги.

Pre-deploy checklist

  • Всі references до попередніх нод через $('NodeName').first().json, не $json
  • Всі Postgres ноди що читають — Settings → Always Output Data: ON
  • Кожен Init State code node обробляє rows.length === 0 як valid path
  • Merge nodes → mode: append (не combineByPosition, крім явних join сценаріїв)
  • Query Parameters у Postgres save-нодах — з явними refs до state-ноди
  • JSONB fields → JSON.stringify(...) + SQL cast ::jsonb
  • Numeric fields → Number(...) + ::numeric
  • Boolean fields → Boolean(...) + ::boolean
  • UPSERT для полів з history (handoff_triggered_at) → COALESCE(existing, new)
  • Test cases: (a) новий session_id, (b) існуючий session_id, (c) кожна гілка Switch окремо