Use
Как мозг (api + LLM) управляет живым worker'ом после того, как тот поднялся: модель сессии, протокол обмена и то, какие тулы где исполняются.
Жизненный цикл: Deploy → Use → Destroy.
Сессия ≠ вызов тула
Один пользовательский запрос → одна живая сессия worker'а → много вызовов тулов по постоянному каналу → один release. Очередь задействуется максимум один раз (чтобы поднять сессию) и никогда — на каждый вызов тула.
| Единица | Через очередь? | Транспорт |
|---|---|---|
Задача / сессия («мне нужен worker для (agent, chat)») | да — один раз (provision) или 0×, если переиспользуется тёплая сессия | 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, по запросу) — и ровно три канала. Не путай их.
| # | Канал | Между | Транспорт | Что несёт | Видит юзер? |
|---|---|---|---|---|---|
| 1 | Chat stream | Юзер ↔ Mind | SSE/WebSocket (стримящий эндпоинт, agentId) | токены ответа, статус «thinking», карточки активности, выбранные артефакты | Да |
| 2 | Provision | Mind → queue → dispatcher | BullMQ на Redis, один раз | «дай мне worker» { agentId, sessionId, caps } + короткоживущий токен | Нет |
| 3 | Канал тулов — MCP | Mind (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 backWorker-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, поэтому то, что мы набросали выше, ложится на него напрямую:
// 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и путешествуют как MCPresource_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_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 (саб-задача 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(саб-задача), а не 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-промпт для этой подсистемы.
- Модель рантайма — поток одного турна от начала до конца.
- Секреты агента — почему secret-тулы остаются в мозге.
- RLM — паттерн работы с данными больше контекстного окна: мозг ведёт цикл, worker служит окружением.