Входящие сообщения: задачи по инициативе пользователя
#1. Цель
Сделать канал коммуникации полноценной точкой входа: пользователь пишет по своей инициативе — без открытого вопроса от агента, без выбранного проекта, без UI — и это сообщение превращается в задачу, уточняющий диалог или вежливый отказ, а не теряется молча.
Флагманский кейс:
«Подготовь проект документа "Смета на разработку по проекту Название" после согласования с Ивановым передай на утверждение в команду Такую-то»
Одно сообщение содержит: задачу подготовки документа, ссылку на проект по названию, согласующего человека по фамилии, команду-получателя по названию и порядок действий (шаги с человеческими контрольными точками). Ни одна из этих ссылок сегодня не разрешается системой.
#2. Что есть сейчас
- Inbound существует только для ответов.
matrixInboundLoopзабирает сообщения через/syncи сопоставляет их (MatchMatrixReplies) с открытыми human requests. Сообщение, не совпавшее ни с одним вопросом, отбрасывается без следа: ни события, ни строки, ни ответа пользователю. - Ран жёстко привязан к проекту.
POST /v1/agent/runsтребует project; триггер требует project; чат UI открывается внутри проекта. Понятия «задача до определения проекта» нет. - Идентификация отправителя комнатная. «Любой в комнате отвечает от имени владельца» — допустимо для ответа на вопрос, недопустимо для порождения работы: кто угодно из комнаты сможет запускать задачи от имени владельца.
- Руководителя нет.
OrgUnit— id/parent/kind/name/path, без head. Эскалация (human_contacts.escalate_for) идёт вверх по дереву до первого уровня с активными людьми;org:вask_humanберёт первого активного пользователя юнита;project_ownerдеградирует в админа инсталляции. - ask_human и команды.
org:<unit-id>доставляется всем активным участникам юнита (fan-out через delivery rows); первый ответ закрывает вопрос, остальные копии отзываются. - Telegram и generic webhook — только доставка. Входящие реплаи поддерживают двусторонние каналы: Matrix (поллинг), Mattermost (WebSocket, включая личные сообщения) и Slack (Events API).
#3. Анатомия кейса
Разбор флагманского сообщения на требования:
| Фрагмент | Что это | Чего не хватает |
|---|---|---|
| «Подготовь проект документа "Смета…"» | новая задача + тип результата (документ) | приём инициативных сообщений; выбор агента |
| «по проекту Название» | ссылка на проект по имени | разрешение проектов по имени + уточнение при неоднозначности |
| «после согласования с Ивановым» | человек по фамилии + контрольная точка | разрешение пользователей по имени (Ивановых много); HITL-gate уже есть (ask_human) |
| «передай на утверждение в команду Такую-то» | юнит по имени + командная точка | разрешение юнитов по имени; адресация команды; руководитель как получатель по умолчанию |
| (порядок «после… передай») | последовательность шагов | многошаговый план — есть (DAG/команды), точек входа в них из канала нет |
Отдельные классы сообщений, которые обязаны разбираются корректно:
- уточнение — «а добавь раздел про риски» (дополнение к уже идущей задаче);
- статус-запрос — «что там со сметой?»;
- отмена — «отмени»;
- не-задача — «спасибо», «до связи» — распознать и не порождать ран;
- вложения — сообщение может начинаться с файла (смета-шаблон в Excel).
#4. Основные понятия
#4.1. Inbound Message
Долговечная строка — тот же паттерн, что HumanRequest: сущность в workspace, события в journal, состояние выводится из сущности.
workspace_inbound_message
id TEXT PRIMARY KEY = inbound/{channel}/{message_id}
channel TEXT -- matrix | telegram | web
message_id TEXT -- event_id матрикса / update_id телеги
sender_raw TEXT -- mxid / chat id
sender_user_id TEXT NULL -- разрешённый workspace-пользователь
room TEXT -- комната/чат доставки
body TEXT
attachments JSONB -- сохранённые файлы (inbox)
status TEXT -- received|classified|clarifying|routed|rejected|expired
classification TEXT -- task|amendment|status|cancel|chatter|reply
intake_run_id TEXT NULL
target_run_id TEXT NULL -- ран, порождённый сообщением
project_id TEXT NULL -- определённый проект
created_at / updated_at
UNIQUE (channel, message_id) -- идемпотентность доставки
id детерминирован: повторная доставка /sync (реплей истории, рестарт
кернела) не порождает вторую обработку.
#4.2. Intake Run
Обработка входящего — это сам агентский ран в системном проекте intake.
Такой проект существует один на инсталляцию, не виден в обычных списках и
содержит только служебные прогоны.
Промпт intake-рана, помимо самого сообщения (дословно) и блока отправителя, несёт блок недавнего диалога комнаты (до 8 предыдущих сообщений): сообщения пользователя — из inbound-строк, ответы агента — из ответов целевых ранов (target_run_id), каждое с датой-временем UTC. Примечание в блоке говорит, что новое сообщение может продолжать один из этих диалогов. Без блока ответ в мессенджере спустя время после завершёния рана приходил как лишённый контекста текст, и intake классифицировал его как новую задачу, хотя пользователь продолжал старый разговор.
Почему ран, а не сервисная функция: приём сообщения — это диалог
(классификация → уточнение → уточнение → запуск), а готовый механизм диалога
с человеком в канале уже существует — ask_human + доставка в комнату +
обратная маршрутизация ответа (matrixInboundLoop) + таймауты с политиками.
Intake-агент переиспользует всё это целиком и не требует нового протокола.
Инструменты intake-агента (все с авторизацией отправителя, см. §10):
human_contacts— уже есть: люди, эскалации;search_projects— проекты, видимые отправителю, поиск по имени;search_org_units— юниты по имени/kind (найти «команду Такую-то»);start_task— создать задачу и запустить ран в целевом проекте (ActorID = отправитель, не intake-система);run_status— статус прогонов, порождённых этим диалогом;cancel_run— отмена с проверкой прав отправителя.
#4.3. Классификация
Двухступенчато, детерминизм насколько возможно:
- Правила. Сообщение совпало с открытым вопросом → это ответ (существующий путь, ничего не меняется). Короткое «отмена/cancel» при активном ране из этого диалога → cancel. Активный intake-ран ждёт уточнения → сообщение становится ответом на него (существующий механизм ответов).
- Модель. Остальное классифицирует intake-агент первым ходом:
task / amendment / status / cancel / chatter. Решение фиксируется в
classificationи событииintake.classified— наблюдаемо и оспоримо.
#5. Идентификация отправителя
Сегодняшнее комнатное соответствие «кто в комнате — тот и владелец» для порождения работы неприемлемо. Вариант по умолчанию — закрытый мир:
- у канала пользователя появляется идентификатор отправителя:
mxidдля Matrix,chat_idдля Telegram (там он уже есть — бот видит только свои чаты); - инициативное сообщение принимается только при точном совпадении
sender_rawс зарегистрированным идентификатором владельца комнаты; - несовпадение →
intake.rejected(reason: unknown_sender) + вежливый ответ в комнату: «напишите из зарегистрированного аккаунта или создайте задачу в приложении»; - выключенный канал владельца (Toggle в личных настройках): комната
остаётся зарегистрированной, идентификация проходит, но работа не
порождается —
intake.rejected(reason: channel_disabled, отправитель атрибутирован) + уведомление «канал отключён в настройках профиля». Молчаливое отклонение здесь читалось пользователем как «сервис перестал обрабатывать все входящие» — особенно когда канал не был preferred и отключение казалось не влияющим на приём; - зашифрованное сообщение (
m.room.encrypted) не читается plaintext-транспортом →intake.rejected(reason: encrypted_unsupported) + уведомление отправителю; молчаливый дроп делал ботом мёртвым для пользователя E2EE-комнаты, пока остальные получали ответы; - для ответов на вопросы действует прежнее комнатное правило (MVP-компромисс сохраняется и документирован) — порождение работы строже, чем ответ.
#6. Разрешение сущностей
Общий принцип: разрешение — это функция поиска с ранжированием и явным уточнением, а не угадывание. Ни одна ссылка не угадывается молча.
#6.1. Проект
Порядок разрешения:
- Явная ссылка. Нормализованный поиск по именам проектов, видимых из позиции отправителя (орг-видимость, org-structure.md §9). Один матч → подтверждение одним сообщением; несколько → уточнение списком; ноль → сразу уточнение, без попыток «похожих».
- Контекст отправителя. Ровно один видимый проект → предложить его как вариант (не молча подставить). Несколько → уточнение.
- Неопределённый проект — состояние, а не сущность: intake-ран держит
задачу, задаёт вопрос списком видимых проектов (+ «создать новый» —
только если разрешено политикой, по умолчанию нет) и живёт до ответа или
таймаута. Таймаут уточнения =
timeout_policyask_human; дефолт для intake —cancelс вежливым итогом в канал: «не понял, к какому проекту относится задача, создайте её в приложении».
Специальный staging-проект для неопределённых задач не заводим: состояние
живёт в intake-ране, восстановления после сбоя — через Temporal, история —
в workspace_inbound_message + событиях.
#6.2. Человек («Иванов»)
Поиск по каталогу (регистронезависимый, по фамилии/имени/id); карточки — как
в human_contacts. Несколько Ивановых → уточнение с вариантами (имя, юнит,
роль). Имена в естественном языке не обязаны быть уникальными — уникален
user_id, его агент и подставляет в ask_human/send_file.
#6.3. Команда («команда Такую-то»)
Поиск по юнитам: имя, kind=team в приоритете, затем department. Несколько матчей → уточнение. Ноль → уточнение с списком юнитов отправителя.
#7. Руководитель оргюнита
Недостающее ребро в дереве:
org_unit.head_user_id TEXT NULL REFERENCES workspace_user(id)
Семантика — та же, что у видимости ресурсов: эффективный руководитель разрешается снизу вверх. У юнита без head руководитель — head ближайшего предка (обычно у команды — руководитель отдела). NULL по всей цепочке — никого (эскалация остаётся на текущем алгоритме близости).
Использование:
- Эскалация.
human_contacts.escalate_forсначала проверяет head ближайшего уровня вверх; близость уровня остаётся фолбэком. Комментарий в contacts.go («дерево не кодирует manager edge») перестаёт быть правдой. - Командные запросы — получатель по умолчанию (§8).
- project_owner — остаётся за админом до появления владельца у проекта (org-structure.md §14), но при привязке проекта к единственному юниту его head — кандидат по умолчанию.
Миграция: колонка nullable, все NULL, поведение не меняется.
#8. Командные запросы
ask_human с получателем-юнитом (org:<unit-id>) сегодня берёт первого
активного пользователя юнита — это не «команда», это случайный человек.
Новая семантика:
- HumanRequest с
recipient = org:<unit-id>доставляется всем активным участникам юнита (каналы + web inbox каждого); - ответ принимается от любого участника, первый ответ закрывает запрос (остальные доставки гасятся как superseded);
- порядок приоритета доставки: head юнита первым в списке, но не авторитетнее по ответу — «кто первый ответил, тот и решил» (MVP; кворум-правила для «утверждения» — отдельная политика, не MVP);
- карточка в UI показывает, что запрос командный и кто из команды ответил.
Для «передай на утверждение команде X» агент (после search_org_units) сам
выбирает форму: адресат — head (строгое утверждение одним лицом) или вся
команда (первый ответ). Подсказка — в промпте intake-агента: для
«утверждения» по умолчанию head, для «согласования» — конкретный человек или
команда целиком.
#9. Многошаговые инструкции
Кейс «после согласования с Ивановым передай на утверждение» — это не механика intake, а содержание задачи. Intake переводит естественную инструкцию в структурированную задачу: проект, заголовок, промпт с чек-поинтами. Исполняющий агент проходит шаги штатными средствами:
- согласование с Ивановым →
ask_human(recipient = разрешённый user_id); - утверждение командой →
ask_human(org: / head, §8); - передача документа →
send_file(вложения и доставка уже есть).
Профиль агента-исполнителя — дефолтный агент проекта, если intake-агент не распознал специализированного (смета → финансовый шаблон — это работа skills/шаблонов агентов, не ядра).
#10. Авторизация, подотчётность, идемпотентность
- Авторизация.
start_taskпроходит те же проверки, чтоPOST /v1/agent/runs: эффективная writer-роль отправителя в целевом проекте, видимость агента/навыков/MCP из позиции отправителя, политика проекта/юнита, квоты. Отказ →intake.rejected(reason: forbidden) с человеческим объяснением в канал. - Подотчётность. Порождённый ран получает
ActorID = sender_user_id: политика и аудит относятся к человеку, а не к системе. Цепочка прозрачна:inbound_message.id → intake_run_id → target_run_id— видна в Runs и аудите. Intake-ран сам исполняется под execution identity (как триггеры, org-structure.md §20–21). - Идемпотентность. Дедуп по
(channel, message_id); intake-ран стартует с детерминированным run id; повторный/sync-реплей не порождает второй обработки.
#11. Наблюдаемость
События (journal, scope системного проекта intake, а для routed — дублируются в scope целевого проекта):
intake.received {message_id, channel, sender, room}
intake.classified {classification, confidence}
intake.clarifying {question, options} -- прокси ask_human
intake.routed {target_run_id, project_id, actor}
intake.rejected {reason: unknown_sender|forbidden|unresolved|not_a_task|encrypted_unsupported}
intake.expired {reason}
UI: страница «Входящие» — список сообщений с классификацией, статусом, переходом в intake-ран и в порождённый ран.
#12. Фазы реализации
Фазы intake строятся поверх слоя каналов (docs/channels.md): общий конвейер
inbound, дедуп и идентификация — там; здесь — только логика приёма задач.
#Фаза 1 — Intake MVP (Matrix)
workspace_inbound_message+ дедуп + события;- mxid в каналах пользователей, закрытый мир отправителя;
- системный проект intake + intake-агент (промпт классификации/уточнения);
- инструменты
search_projects,search_org_units,start_task; - разрешение проекта (§6.1), уточнение через ask_human (маршрутизация ответов уже работает);
- страница «Входящие» (минимум: список + статусы).
#Фаза 2 — Руководитель и команды
org_unit.head_user_id+ эффективный head;escalate_forчерез head;human_contacts— head в карточке юнита;- командная доставка
org:(fan-out, первый ответ), head в UI дерева; run_status,cancel_run— статус-запросы и отмена из канала.
#Фаза 3 — Диалог и Telegram
- Уточнения задаче (amendment → follow-up ран с контекстом диалога);
- inbound-поллинг Telegram (getUpdates, chat_id = идентификатор) — как драйвер поверх слоя каналов, не отдельный луп;
- вложения в инициативных сообщениях (механика inbox уже есть).
#Фаза 4 — Политика и эксплуатация
- Включение intake по проекту/юниту (по умолчанию выключено);
- лимиты частоты на отправителя; удержание тел сообщений (PII);
- аудиторские отчёты по цепочке сообщений.
#13. Критерии готовности
Флагманский сценарий, конец-в-конец,_matrix:
- Пользователь пишет кейс из §1 из своей комнаты;
- intake-ран: находит проект «Название» (один матч), Ивановых два — задаёт уточнение, пользователь отвечает в канале;
- задача стартует в проекте от имени пользователя (ActorID), агент готовит
документ, согласовывает с Ивановым (
ask_human), команда получает файл (send_file+ командный запрос); - Runs показывает дерево: сообщение → intake → задача;
- Повторная доставка того же event_id не порождает дубль;
- Сообщение от постороннего mxid вежливо отвергнуто;
- «что там со сметой?» возвращает статус без нового рана;
- «отмени» останавливает порождённый ран (с проверкой прав).
#14. Открытые вопросы
- Где живёт классификационная модель и её стоимость: intake-агент на общей модели или дешёвая отдельная для первого хода (замерить на пилоте).
- Настройка intake-агента по юнитам: язык/тон приветствия и правил уточнения.
- Хранение тел сообщений: срок, маскирование, вычистка (PII в journal).
- Создание нового проекта из канала — допускать ли при явном запросе (по умолчанию нет, опасная операция).
- Кворумные правила для «утверждения» командой (все должны / большинство / head достаточно) — после пилота фазы 2.