Skip to content

Use

Как мозг (api + LLM) управляет живым worker'ом после того, как тот поднялся: модель сессии, протокол обмена и то, какие тулы где исполняются.

Жизненный цикл: DeployUseDestroy.

Сессия ≠ вызов тула

Один пользовательский запрос → одна живая сессия worker'а → много вызовов тулов по постоянному каналу → один release. Очередь задействуется максимум один раз (чтобы поднять сессию) и никогда — на каждый вызов тула.

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

Почему живая сессия, а не Job на каждый тул

Это обязательное требование, а не просто оптимизация: тулы внутри турна разделяют состояние — рабочую директорию (cd), залогиненный контекст браузера, окружение, фоновые процессы. Свежий Job на каждый вызов тула потерял бы всё это и платил бы за спин-ап пода каждый раз. Поэтому worker остаётся живым на время сессии, а мозг загоняет в него много вызовов тулов.

Кто крутит цикл: мозг

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

Протокол — две плоскости, три канала

Контракт провода написан один раз и лежит отдельно

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

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

#КаналМеждуТранспортЧто несётВидит юзер?
1Chat streamЮзер ↔ MindSSE/WebSocket (стримящий эндпоинт, agentId)токены ответа, статус «thinking», карточки активности, выбранные артефактыДа
2ProvisionMind → queue → dispatcherBullMQ на Redis, один раз«дай мне worker» { agentId, sessionId, caps } + короткоживущий токенНет
3Канал тулов — MCPMind (MCP host) ↔ Worker (MCP server)MCP поверх WebSocket'а, инициированного worker'ом, постоянный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 пода — он переживает перепланирования и не требует входящей маршрутизации. 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" } }
  • Обнаружение: tools/list и есть allowlist/профиль, сделанный явным; notifications/tools/list_changed, если он меняется в середине сессии.
  • Sticky-состояние: cwd, контекст браузера, env и фоновые процессы сохраняются между вызовами — инстанс MCP-сервера stateful на всё время жизни сессии.
  • Большие полезные данные — по ссылке: файлы / скриншоты / дампы уходят в объектное хранилище через system/file и путешествуют как MCP resource_link, никогда не инлайном.

Каждый результат тула скармливается обратно в LLM, которая решает следующий tools/call. Этот цикл может крутиться автономно от секунд до ~часа по открытой MCP-сессии, с нулевым дополнительным трафиком очереди.

Что доходит до чата (а что — нет)

Пользовательский чат (канал 1) курируется LLM, а не является сырым фидом канала 3:

В чате (канал 1)Только внутри (канал 3 / control plane)
Прозаический ответ агентаСырой трафик MCP tools/call
Состояние «thinking / working…»Логи worker'а и progress-уведомления
Карточки активности — «🌐 Browsing example.com…», «✅ found the price»Промежуточные скриншоты / дампы
Артефакты, которые агент решает показатьПолный трейс действий (только 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браузеравтоматизация headless Chromium in-pod
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 (саб-задача 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 (саб-задача), а не 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-промпт для этой подсистемы.
  • Модель рантайма — поток одного турна от начала до конца.
  • Секреты агента — почему secret-тулы остаются в мозге.
  • RLM — паттерн работы с данными больше контекстного окна: мозг ведёт цикл, worker служит окружением.