Use
Як мозок (api + LLM) жене живий worker після того, як він піднявся: модель сесії, протокол обміну й те, які тули де виконуються.
Життєвий цикл: Deploy → Use → Destroy.
Сесія ≠ виклик тула
Один запит користувача → одна жива сесія worker'а → багато викликів тулів через персистентний канал → один release. Черга чіпається щонайбільше раз (щоб провіженити), ніколи не на кожен виклик тула.
| Одиниця | Через чергу? | Транспорт |
|---|---|---|
Задача / сесія («мені потрібен worker для (agent, chat)») | так — раз (провіжен), або 0×, якщо перевикористовується тепла сесія | BullMQ |
Виклик тула (exec, browser_play, …) | ні | живий WebSocket до запущеного worker'а |
Чому жива сесія, а не Job на кожен тул
Це необхідність, а не просто оптимізація: тули в межах турну ділять стан — робочу директорію (cd), залогінений контекст браузера, оточення, фонові процеси. Свіжий Job на кожен виклик тула втрачав би все це й платив би latency підняття пода щоразу. Тож worker лишається живим на час сесії, а мозок жене в нього багато викликів тулів.
Хто крутить цикл: мозок
orchestrator (в api) володіє циклом LLM-міркувань; worker — це stateful-сервер виконання тулів без LLM. Мозок вирішує «виклич exec», відправляє виклик, отримує результат, згодовує його LLM, повторює. Бонус безпеки: цикл лишається в мозку, тож API-ключі LLM ніколи не потрапляють у под worker'а — пісочниця тримає лише виконавців тулів.
Протокол — дві площини, три канали
Контракт дроту написаний один раз і лежить окремо
Цей розділ — мапа: дві площини, три канали, що де їде. Сам контракт дроту для каналу 3 (кадри initialize / ready / heartbeat / release, що вважається відповіддю на кожен, що відбувається при мовчанні і що роблять сторони, коли версії не збіглися) — це Канал інструментів, написаний один раз, щоб шлюз і пісочниця не розійшлися.
Дві площини — Mind (api + LLM, персистентна) і Hands (worker, на вимогу) — і рівно три канали. Не плутай їх.
| # | Канал | Між | Транспорт | Несе | Користувач бачить? |
|---|---|---|---|---|---|
| 1 | Chat stream | User ↔ Mind | SSE/WebSocket (streaming endpoint, agentId) | токени відповіді, статус «thinking», activity-картки, обрані артефакти | Так |
| 2 | Provision | Mind → queue → dispatcher | BullMQ на Redis, раз | «дай мені worker» { agentId, sessionId, caps } + короткоживучий токен | Ні |
| 3 | Tool channel — MCP | Mind (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 backWorker-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, тож те, що ми накидали раніше, лягає на нього напряму:
// 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і їдуть як MCPresource_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_fetch | egress (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.
Прикордонні випадки (вирішено)
http/web_fetch→ worker — довільні URL — це SSRF-ризик; мозок (DB + KEK) не має їх тягнути. Це робить пісочниця, за egress-allowlist'ом.pdf_analyze→ worker,image_analyze→ мозок — PDF потребує локального бінарника + файлу на диску; аналіз зображення — це vision-виклик LLM.spawn_agent→ мозок/orchestrator — суб-агент — це ще один ефемерний запускapi(sub-task), а не subprocess у пісочниці.- Тул
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, а busyboxwget), а хост зі списку, який резолвиться в 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 слугує середовищем.