Skip to content

Use

Як мозок (api + LLM) жене живий worker після того, як він піднявся: модель сесії, протокол обміну й те, які тули де виконуються.

Життєвий цикл: DeployUseDestroy.

Сесія ≠ виклик тула

Один запит користувача → одна жива сесія worker'а → багато викликів тулів через персистентний канал → один release. Черга чіпається щонайбільше раз (щоб провіженити), ніколи не на кожен виклик тула.

ОдиницяЧерез чергу?Транспорт
Задача / сесія («мені потрібен worker для (agent, chat)»)так — раз (провіжен), або , якщо перевикористовується тепла сесіяBullMQ
Виклик тула (exec, browser_play, …)ніживий WebSocket до запущеного worker'а

Чому жива сесія, а не Job на кожен тул

Це необхідність, а не просто оптимізація: тули в межах турну ділять стан — робочу директорію (cd), залогінений контекст браузера, оточення, фонові процеси. Свіжий Job на кожен виклик тула втрачав би все це й платив би latency підняття пода щоразу. Тож worker лишається живим на час сесії, а мозок жене в нього багато викликів тулів.

Хто крутить цикл: мозок

orchestratorapi) володіє циклом LLM-міркувань; worker — це stateful-сервер виконання тулів без LLM. Мозок вирішує «виклич exec», відправляє виклик, отримує результат, згодовує його LLM, повторює. Бонус безпеки: цикл лишається в мозку, тож API-ключі LLM ніколи не потрапляють у под worker'а — пісочниця тримає лише виконавців тулів.

Протокол — дві площини, три канали

Контракт дроту написаний один раз і лежить окремо

Цей розділ — мапа: дві площини, три канали, що де їде. Сам контракт дроту для каналу 3 (кадри initialize / ready / heartbeat / release, що вважається відповіддю на кожен, що відбувається при мовчанні і що роблять сторони, коли версії не збіглися) — це Канал інструментів, написаний один раз, щоб шлюз і пісочниця не розійшлися.

Дві площини — Mind (api + LLM, персистентна) і Hands (worker, на вимогу) — і рівно три канали. Не плутай їх.

#КаналМіжТранспортНесеКористувач бачить?
1Chat streamUser ↔ MindSSE/WebSocket (streaming endpoint, agentId)токени відповіді, статус «thinking», activity-картки, обрані артефактиТак
2ProvisionMind → queue → dispatcherBullMQ на Redis, раз«дай мені worker» { agentId, sessionId, caps } + короткоживучий токенНі
3Tool channel — MCPMind (MCP host) ↔ Worker (MCP server)MCP поверх ініційованого worker'ом WebSocket, персистентнийinitialize · tools/list · tools/call · progress · resource_linkНі

Розмова mind↔worker їде по каналу 3 — окремий внутрішній канал, ніколи не чат користувача. Лише курований статус і саммарі переходять із 3 у 1.

Worker — це ефемерний MCP-сервер. Канал 3 — це звичайний MCP (Model Context Protocol — той самий JSON-RPC-стандарт тулів, який ми використовуємо для конекторів сторів). Мозок (api) — це MCP host: він агрегує кілька MCP-серверів і виставляє їхні тули LLM як один набір тулів —

  • brain-MCP (in-process): memory · secret · cron · channel …
  • worker-MCP (под): exec · fs · browser … — під'єднується на ready, від'єднується на release
  • connector-MCP (зовнішній): API стора (OpenCart), інтеграції

Локус тула — це просто те, який MCP-сервер його хостить. Worker не має LLM; MCP sampling (виклики моделі, ініційовані сервером) вимкнено — цикл міркувань лишається в мозку.

Handshake (після старту пода)

LLM emits a tool_call (e.g. browser.*)
   │  channel 2 — once
   ▼  Mind enqueues provision { agentId, sessionId, caps } + mints WORKER_TOKEN (sub = sessionId)
dispatcher provisions the worker pod (env: SESSION_ID · CONTROL_URL · WORKER_TOKEN_FILE · allowlist · storage scope;
                                     the pass itself is a projected Secret mounted at that path, never an env value)
   │  channel 3 — worker-initiated
   ▼  worker boots → dials CONTROL_URL over WebSocket → MCP `initialize` (presents WORKER_TOKEN)
api worker-gateway (MCP host) validates the token, matches sessionId, runs `tools/list`,
       registers the worker's MCP server into the session toolset → "ready"

the LLM now sees the worker's tools; the host routes `tools/call` to THIS worker; results stream back

Worker-initiated dial-back означає, що api ніколи не потрібен IP пода — сесія переживає reschedule'и й не потребує вхідного роутингу. Heartbeat'и (ws ping/pong) + idle-timeout → осиротілий worker самознищується.

Транспорт — вирішено (варіант A): MCP їде поверх реверсного WebSocket (worker дзвонить host'у). Worker є TCP-ініціатором, але водночас лишається MCP сервером; gateway — це MCP клієнт. Це зберігає «api ніколи не тримає IP пода, жодного вхідного трафіку в пісочницю» — ціною невеликого кастомного MCP-транспорту замість стокового Streamable-HTTP. MCP session id ↔ наш sessionId.

На дроті (MCP)

MCP — це JSON-RPC 2.0, тож те, що ми накидали раніше, лягає на нього напряму:

jsonc
// host → worker — discover tools (once, on connect) — allowlist/profile-gated
{ "method": "tools/list" }
//  → { "tools": [ { "name": "browser.click", "inputSchema": { … } }, … ] }

// host → worker — call a tool
{ "method": "tools/call", "params": { "name": "browser.click", "arguments": { "selector": "#buy" } } }
//  → { "content": [ { "type": "text", "text": "ok" } ],
//      "structuredContent": { "url": "/cart" },
//      "_meta": { "screenshot": { "type": "resource_link", "uri": "store://art_88" } } }

// worker → host — progress during a long action
{ "method": "notifications/progress", "params": { "progressToken": "c12", "message": "navigated to /cart" } }
  • Discovery: tools/list і є allowlist/профіль, зроблений явним; notifications/tools/list_changed, якщо він змінюється посеред сесії.
  • Sticky-стан: cwd, контекст браузера, env і фонові процеси зберігаються між викликами — інстанс MCP-сервера stateful на час життя сесії.
  • Великі payload'и за посиланням: файли / скриншоти / дампи йдуть в об'єктне сховище через system/file і їдуть як MCP resource_link, ніколи не inline.

Кожен результат тула згодовується назад в LLM, який вирішує наступний tools/call. Цей цикл може йти автономно секунди — до ~години через відкриту MCP-сесію, з нульовим додатковим трафіком черги.

Що доходить до чату (а що ні)

Чат користувача (канал 1) курується LLM, а не є сирим фідом каналу 3:

У чаті (канал 1)Лише внутрішнє (канал 3 / control plane)
Прозова відповідь агентаСирий трафік MCP tools/call
Стан «thinking / working…»Логи worker'а та progress-нотифікації
Activity-картки — «🌐 Browsing example.com…», «✅ found the price»Проміжні скриншоти / дампи
Артефакти, які агент обирає показатиПовний trace дій (лише debug/trace-в'ю)

Порядок такий: агент думає → жене руки через канал 3 → отримує факти → вирішує, що сказати людині через канал 1. Worker ніколи не пише в чат; це робить LLM, після саммарі.

Розподіл тулів — що де виконується

Тул виконується в worker'і тоді і тільки тоді, коли йому потрібна пісочниця: мутабельна файлова система, спавн процесів, браузер або довільний/недовірений мережевий egress (SSRF-ізоляція). Усе інше лишається в мозку — виклики LLM, стан agent-домену, оркестрація, презентація.

У термінах MCP: тули worker'а обслуговує worker-MCP, тули мозку — brain-MCP — LLM бачить один злитий набір тулів, а host маршрутизує кожен виклик до потрібного сервера.

Тули worker'а (пісочниця)

ТулПотребуєЩо робить
exec / process_execпроцесизапуск / керування shell-командами й фоновими процесами
file / unzipробочу FSчитати/писати робочу директорію пода (≠ system/file); розпаковувати архіви
browser / browser_screenshot / browser_playбраузеравтоматизація in-pod headless Chromium
http / web_fetchegress (SSRF)дістати довільний URL → відповідь / чистий текст
pdf_analyzeбінарник + FSвитягти текст через pdftotext з файлу на диску

Тули мозку (не в worker'і)

web_search, image_analyze, tts (LLM/зовнішні) · memory_* (agent/memory) · secret_* (agent/secretлише мозок; KEK ніколи не їде в под) · cron_* (agent/cron) · channel_* · access · skill_write · spawn_agent (sub-task orchestrator'а) · render_form / telegram_send (презентація) · resource_status.

Прикордонні випадки (вирішено)

  1. http / web_fetch → worker — довільні URL — це SSRF-ризик; мозок (DB + KEK) не має їх тягнути. Це робить пісочниця, за egress-allowlist'ом.
  2. pdf_analyze → worker, image_analyze → мозок — PDF потребує локального бінарника + файлу на диску; аналіз зображення — це vision-виклик LLM.
  3. spawn_agent → мозок/orchestrator — суб-агент — це ще один ефемерний запуск api (sub-task), а не subprocess у пісочниці.
  4. Тул file ≠ сервіс system/file — тул чіпає ефемерний workspace; сервіс зберігає артефакти. На завершенні задачі workspace синхронізується в system/file.

Безпека

  • Жодних секретів у worker'і. CRUD секретів — лише в мозку; dispatcher JIT-інжектить тільки ті секрети, що потрібні задачі, як короткоживучий projected Secret — KEK ніколи не покидає api.
  • Egress-allowlist — сьогодні в інструменті, а не в мережі. http і web_fetch перевіряють список призначень перед кожним переходом, а не один раз на початку: список, до якого звернулися лише на тій адресі, яку написала модель, — не список, а формальність, через яку переступає 302. Записи — це хости (example.com, *.example.com), не URL і не порти, і * не приймається: «дозволити все» в одному символі від законного запису — це те, як воно сталося б через недогляд. Запис, який не вдалося розібрати, викидається, а не розширюється, і називається в лозі старту. Щоб сказати «куди завгодно», є одне точне слово: internet:unrestricted. Воно допускає будь-який хост, api і воркер розуміють його однаково, і це слово, а не символ, рівно з тієї причини, з якої відхиляється *: двадцять один символ із двокрапкою посередині не набирається випадково. Рахується воно з обох боків: агент, який попросив його за стелі з перелічених хостів, дістає гучну відмову з називанням і запитаного, і стелі, а необмежена стеля сама по собі нікуди не відправляє агента, який нічого не просив. У списку два джерела, і виграє вужче. Стеля викатки (RUNTIME_WORKER_EGRESS_ALLOWLIST) — це все, куди воркер цієї інсталяції може ходити взагалі, і зсунути її орендар не може; запит самого агента (config.egress) — це те, що просять його сесії. Кожен запис запиту зобов'язаний потрапити всередину стелі, інакше сесія відхиляється ще до того, як з'явиться, з називанням обох списків. Незадане з будь-якого боку означає «нікуди», і саме це каже сьогодні кожен рядок агента, — тому ніщо з того, що працює зараз, не почне дотягуватися до мережі. Жодне джерело окремо не годиться: стеля сама по собі — це один список на всіх і назавжди, а список агента сам по собі віддає рішення тому, хто може правити агента, тобто орендареві. Список їде вниз у маніфесті пода, на сесію, і ставить його провіжинер — тому каналом інструментів його розширити не можна, і він на місці до першого виклику, а не підвантажується після старту. Ключ пишеться присутнім і порожнім, а не опускається, щоб маніфест більше ніколи не казав «зачинено», не сказавши нічого. Сама ця перевірка пода не втримує. Вона живе в інструменті, тобто в коді, який працює всередині пісочниці, у тому самому процесі, що виконує команди моделі: exec може відкрити сокет, якого список не побачить (в образі не curl, а busybox wget), а хост зі списку, який резолвиться в link-local адресу, буде допущений. Другий рівень — NetworkPolicy з default-deny — існує починаючи з AGNT2-204, це k8s/runtime/worker-egress.template.yaml. Списку вище він не несе і нести не може: така політика зіставляє CIDR і селектори подів, а не імена хостів, — тому під нею воркер дістається резолвера кластера й каналу керування додому, і більше нікуди. Звести два рівні (CNI з FQDN-політикою або egress-проксі) — усе ще відкрита робота, і доти видану адресу дозволяє інструмент, а вузол відхиляє.
  • Дія на сторінці — ОКРЕМИЙ дозвіл (AGNT2-221). browser_play вміє вписати текст у форму та натиснути кнопку, а кнопка — це покупка, видалення, згода з умовами. Відкрити сторінку й зачекати на ній дозволяє список вище; вписувати й натискати дозволяє ДРУГИЙ списокBROWSER_PLAY_ALLOWLIST у маніфесті, config.browserPlay в агента, — і ніщо з написаного у списку доступу не кладе до нього жодного хоста. Дозвіл читати сайт не є дозволом на ньому натискати: власник, який вписав крамницю до config.egress, сказав «можеш дивитися на крамницю», і прочитати це як «можеш у ній купувати» означало б мовчки розширити кожен виданий дозвіл. Граматика в нього навмисно вужча. Лише точні імена хостів: *.example.com відкидається, бо маска — це твердження про сімейство сайтів, більшості з яких той, хто її пише, ніколи не бачив; і internet:unrestricted відкидається в будь-якій позиції, бо в читання слово «куди завгодно» є, а в дії його навмисно немає. Кожен запис має до того ж потрапляти у власний список доступу цього агента — не можна діяти там, куди не можна ходити. Не задано — означає ніде, і саме це каже сьогодні кожен рядок агента. Перевірка робиться наново перед кожною дією, за адресою, до якої сторінка справді дійшла: перейшовши за посиланням на сайт, який можна лише читати, агент лишається здатним його читати й не більше.
  • Ефемерний workspace. Тули worker'а чіпають лише emptyDir пода; він стирається при стопі, виходи копіюються в system/file до видалення Job.

Дивіться також

  • Deploy — як worker провіжиниться й стартує.
  • Канал інструментів — контракт дроту за цим розділом, написаний один раз.
  • Destroy — release, idle-reaper, TTL, очистка.
  • Implementation — build-prompt для цієї підсистеми.
  • Модель рантайму — потік одного турну від початку до кінця.
  • Секрети агента — чому тули секретів лишаються в мозку.
  • RLM — патерн роботи з даними більшими за контекстне вікно: мозок веде цикл, worker слугує середовищем.