dsh-telegram-multiagent
Канал Telegram для платформы DeepSeek Harness. Один модуль на машину обслуживает
несколько агентов: у каждого свой бот, свой файл токена и свой список тех, кому он
отвечает. Агенты не видят переписку друг друга.
Установка
npm install dsh-telegram-multiagent
Строка подключения кладётся в пресет агента (см. «Куда класть строку» ниже):
# внутри каталога профиля ($DSH_HOME/profiles/<имя>)
Настройки
| Поле | Обязательно | Что делает |
|---|
tokenFile | одно из трёх | Путь к файлу с токеном бота. Предпочтительно: секрет принадлежит машине, настройка несёт только путь. |
token / tokenEnv | одно из трёх | Сам токен либо имя переменной окружения. Для отладки. |
appDir | да | Каталог, где установлена платформа этого агента. Пакеты платформы разрешаются отсюда, а не от места самого плагина — см. «Почему appDir». |
agentName | нет | Подпись в строках журнала. Полезно, когда на машине несколько ботов. |
allowedUsers | нет | Числовые идентификаторы тех, кому можно говорить с агентом. Пусто означает «всем» — а у агента обычно есть настоящий доступ к машине, так что задайте. |
workspace | нет | Рабочий каталог, передаваемый сессии агента. |
preset | нет | Пресет агента для сессий, созданных этим каналом. |
provider / model | нет | Запасной вариант, если в установке нет службы модели по умолчанию. |
a2aDir | нет | Каталог файлового канала между агентами (in/, out/). Не задан — канала нет. |
a2aSession | нет | Идентификатор сессии для этого канала. По умолчанию a2a. |
spoolDir | нет | Системный почтовый ящик агента. Не задан — ящик не читается. |
transcribeCommand | нет | Внешняя команда для голосовых: <команда> <файл> auto → расшифровка в stdout. Не задана — голос вежливо отклоняется. |
goalUsers | нет | Кому можно ставить цель из Telegram. Пусто — команда отвергается для всех. Намеренно не наследуется от allowedUsers: говорить с агентом и запускать ему платный автономный цикл — разные права. |
goalA2ASenders | нет | Кому можно ставить цель из канала между агентами. Отправитель называет себя первой строкой файла From: <имя>. Это учёт, а не удостоверение личности. |
🔴 Предел: 3 постановки цели за скользящий час. Считается по отметкам состоявшихся
постановок, отдельно на каждый канал. Исчерпан — команда отвергается со словами «зовите
человека». СНЯТИЕ цели при исчерпанном пределе разрешено: предел бережёт от разгона, а не
запирает возможность остановиться.
Предел не защищает от намеренного действия агента с правами администратора: такой агент
снимет и счётчик, и сам предел. Защита здесь от разгона и случайности, а не от намерения.
Пять вещей, стоивших нам дня
Каждая из них отказывает выглядя успехом. В исходниках они помечены комментариями
рядом с местом; здесь короткая версия.
1. Опрос принадлежит боту, а не монтажу. Платформа монтирует композицию больше
одного раза на процесс и снимает лишний монтаж. С одним общим флагом «идёт» вы
получите два опроса, дерущихся за одного бота, и Telegram оборвёт оба с ошибкой
Conflict. С вежливым «новый монтаж просит старый посторониться» вы не получите
опроса вовсе — потому что снимут именно новый. Лечение — счёт ссылок: первый монтаж
запускает опрос, последний снятый его останавливает.
2. Входящее письмо удаляется только после того, как дошло до агента. Прочитать
файл и сразу удалить — значит терять сообщение всякий раз, когда передача не удалась.
А она не удастся, см. (3). Снаружи это неотличимо от «бот меня проигнорировал».
3. Фабрика агентов появляется позже канала. Первое сообщение может прийти раньше,
чем платформа её зарегистрировала, и вы получите no agent factory registered. Ждите
платформу, а не предполагайте, что она готова.
4. Сессию, уже лежащую на диске, надо продолжать, а не создавать. Вызов create()
с существующим идентификатором даёт новую пустую сессию, и переписка начинается с
чистого листа — при том, что старая цела и лежит рядом.
5. Отказ незнакомцу должен быть громким. Молчаливый отказ прячет событие
безопасности: владелец не узнает, что кто-то нашёл его бота.
Куда класть строку
В профиле web инструменты общего плана выключены намеренно; набор приходит из
пресета агента. Строка плагина, положенная в слой патча профиля, соберётся без единой
ошибки, будет числиться смонтированной — и никогда не дойдёт до агента. Кладите строку
в пресет агента.
Пресет читается ещё и при создании сессии: правка файла не действует на уже идущую.
Перезапустите платформу либо смените пресет по умолчанию в настройках (перечитывается на
ходу, действует для следующей созданной сессии).
Почему appDir
Плагин разрешает @deepseek-ai/dsh-llm и @deepseek-ai/dsh-agent из каталога, который
вы передали в appDir, а не от собственного расположения. Это сделано намеренно: модуль
должен лежать на машине один раз и обслуживать нескольких агентов, у каждого своя
установка платформы. Привяжи его жёстко к node_modules одного агента — и удаление
ЭТОГО агента сломает канал всем остальным.
Заметки о безопасности
- Пустой
allowedUsers означает, что любой нашедший бота говорит с агентом, у которого
обычно есть доступ к оболочке машины. Задайте его.
- Отвергнутый незнакомец попадает в журнал и в сообщение владельцу. Молчаливый отказ
прячет событие безопасности.
- Токен читается из файла при запуске; настройка несёт путь, а не секрет.
- Изоляция агентов держится на правах файлов токенов, а не на самом модуле: модуль
общий, разделяет их файловая система.
Состояние
Написан для собственного парка агентов и работает в бою на нескольких. Платформа молодая
(0.1.0-rc), её интерфейс плагинов меняется — рассчитывайте подстраиваться. Комментарии
в исходниках русские: они несут причину каждой неочевидной строки.
MIT.
1.4.3 — команды платформы прямо из чата
Ломающих изменений НЕТ. Добавлены два стенда, из состава ничего не пропало.
Сообщение, начинающееся со слэша, теперь отдаётся реестру команд платформы,
а не модели. До этого владелец слал /compact, агент отвечал «сейчас сожму» —
и не сжимал ничего: у модели такого инструмента нет вовсе.
| Команда | Что делает |
|---|
/compact, /ccc | Штатное сжатие истории (dsh-command-compact). |
/compact-status, /cs | Сколько занято контекста и далеко ли до порога сжатия. Команда наша: у платформы своей нет. |
Всё остальное со слэшем модель получает как обычный текст: реестр отвечает
undefined на незнакомое имя, и проглотить такое сообщение было бы хуже, чем
не понять его.
Ответ называет сессию, над которой выполнен. Строчка (сессия <id>)
приписывается к любому ответу команды. Стоила она получаса пустого
расследования: команда была подана из личного чата, а сжалась другая сессия —
выглядело дефектом, а было настройкой mergeChatIntoA2A, сводящей два канала
в одну память. Числа настоящие, расхождение настоящее, объяснения нет.
Четыре правила, купленные боем
Служба берётся через ctx.get(имя), а не чтением ctx.имя. Платформа
запрещает читать службу без объявления в inject, и проверяет это в момент
обращения, а не при монтаже. Обе команды владельца упали в бою на
cannot get property "commands" without inject.
И не через inject. Объявление встало бы в pending и обрубило связь с
владельцем целиком: канал важнее команды. Проверено канарейкой на другой службе.
Получение службы — внутри try, а общий перехватчик тоже отвечает в чат.
Первая правка бросок вылечила, но он улетал в общий перехватчик обновления и
оставался только в журнале: владелец не получал ни ответа, ни отказа. Условие
«отвечать при любом исходе» было выполнено внутри try и не выполнено снаружи —
у защиты было место, где она не применяется, и оно не было названо.
Алиасы разворачиваются в имя штатной команды, а не в свою реализацию.
/ccc → /compact. Прямой вызов движка сжатия сработал бы, но не записал бы
событие command/run — то самое, которым отличают «команда не дошла» от
«механизм молчит». Чинить доставку способом, ослепляющим проверку доставки,
нельзя. Концевой якорь в шаблоне обязателен: без него /compactstatus
сматчится как /compact и молча сожмёт историю.
Чего /compact-status не выдумывает
Занятость берётся у платформенного tokenMeter.measure — тем же числом
платформа решает, пора ли сжимать. Не из usage последнего ответа: там
inputTokens бывает равен двум при шестистах тысячах прочитанного из кэша.
Окно контекста спрашивается у службы llm тем же путём, каким его берёт
сама платформа: цель из заголовка запроса сессии, окно — у адаптера модели.
Не нашлось — команда так и говорит, называя причину, и умолчание не
подставляет. Выдуманное окно выглядит как замер и врёт ровно там, где на
него посмотрят.
Порог считается как «окно × 0.8». 0.8 — умолчание платформы, и оно верно
ровно потому, что в нашем составе сжатие смонтировано без своей настройки.
Переопределите thresholdRatio — это число станет неверным, и стенд не
покраснеет: он читает наш файл, а не ваш состав.
1.4.0 — причина сетевого отказа и чтение почтового ящика
Ломающих изменений НЕТ. Проверено сравнением состава: из 1.3.0 не пропало ни одного
файла, добавилось два стенда. Пути внутри пакета прежние.
пропало: ничего
добавилось: test/stend-imena.mjs, test/stend-yashchika-shiny.mjs
изменилось: src/index.js, test/test-goal-control.mjs, cordis.patch.yml, README.md
Причина сетевого отказа печатается рядом с сообщением. Раньше fetch failed скрывал
причину: обрыв соединения, истёкшее ожидание и ненайденное имя выглядели одинаково, и по
журналу нельзя было понять, что чинить. Теперь рядом печатается e.cause. Если причины
нет — пишется «не указана», а не пустое место: пустое читается как «причины нет» и
возвращает ту же слепоту.
🔴 У двух каналов РАЗНЫЕ способы назвать отправителя, и путать их дорого:
канал a2a (каталог a2aDir) — отправитель из заголовка первой строки: From: <имя>
почтовый ящик (spoolDir) — отправитель из ИМЕНИ ФАЙЛА: ГГГГММДДTЧЧММССZ-<имя>-<хвост>
Положив в ящик письмо с заголовком From:, вы получите отправителем то, что стоит
в имени файла, а сам заголовок останется в тексте письма. Ошибки не будет, отказа
тоже — просто отправитель окажется не тем, кого вы назвали. Проверено пробой.
Причина разницы: в ящик кладёт посторонний процесс правами каталога, и имя файла —
единственное, что он не может подделать незаметно для владельца ящика. В канале a2a
файл пишет сам отправитель, там заголовок и есть его подпись. В обоих случаях это
учёт, а не удостоверение личности.
И третье следствие того же различия: команды (/goal) из ящика не принимаются вовсе —
они живут только в канале a2a, где отправитель подписывает файл сам. Положив /goal в
ящик, вы получите обычное письмо с этим текстом: ни выполнения, ни отказа. Право ставить
цель поимённое, и проверять его по имени файла, которое задаёт посторонний процесс,
означало бы раздать это право владельцу каталога.
Чтение писем из системного почтового ящика (spoolDir). Письмо старше 12 часов
подаётся с явной пометкой возраста — иначе двухсуточное письмо толкает отвечать на
устаревшее. Больше пяти писем подаются одной пачкой, а не по одному. Ящик не задан —
не читается, молчаливого умолчания нет.
Блок управления целями появился в cordis.patch.yml. Раньше goalUsers и
goalA2ASenders были описаны в README, но отсутствовали в патче монтажа — то есть
настройка была недоступна тому, кто ставил пакет по инструкции.
Проверка, что работает
Стенды едут вместе с кодом:
node test/test-goal-control.mjs управление целями из канала
node test/stend-imena.mjs метки отправителя и обезличенность
node test/stend-yashchika-shiny.mjs чтение почтового ящика
Коды: 0 — сошлось, 1 — расхождение, 2 — проверить нечем (слепота).
🔴 Код 2 не означает «всё хорошо»: он означает, что часть проверок не состоялась. Чаще
всего это отсутствие необязательных зависимостей или файлов настроек, которых у только
что установленного модуля ещё нет.
Чего 1.4.0 НЕ делает
- не хранит переписку — она живёт в журнале сессии платформы;
- не гарантирует доставку при недоступной сети: повторяет опрос и пишет причину отказа в
журнал, но письмо в этот момент не уходит;
- не проверяет, что пишущий в канал между агентами — тот, за кого себя выдаёт: метка
отправителя берётся из имени файла, а право положить файл раздаётся правами каталога.
1.1.0 — кто спросил, кому отвечено, и кто это видит
Три возможности, выросшие из одной задачи: с агентом работают ДВОЕ — владелец из личного чата и
координатор по служебному каналу, — и они должны делить одну память, не путаясь, кто есть кто.
Пометка отправителя, которую нельзя подделать. Каждое входящее помечается каналом, из которого
пришло. 🔴 Ключевое: перед тем как поставить свою пометку, модуль ВЫРЕЗАЕТ из текста всё похожее на
неё. Без этого пометка была бы подписью в тексте, а подпись подделывает любой, кто умеет печатать.
Проверено нападением: сообщение из личного чата с готовой строкой «служебный канал» приходит
агенту помеченным как личный чат.
Общая память двух собеседников. mergeChatIntoA2A: <идентификатор чата> — сообщения этого чата
идут не в свою сессию, а в служебную. Один агент, одна история разговора, два различаемых лица.
Побочно лечит столкновение имён инструментов: второй экземпляр набора не монтируется вовсе.
Адресат ответа привязан к ХОДУ, а не к последнему сообщению. Ответ помечается [ответ: кто] с
цитатой вопроса. 🔴 Почему не «отвечаем последнему спросившему»: если второй вопрос пришёл, пока
первый считается, ответы разъезжаются не тем — причём с правильными на вид пометками.
Режим доставки — в файле ВНЕ кода. Путь берётся из settingsFile (по умолчанию — файл токена с
расширением .json). Читается на лету по времени изменения: правка действует со следующего
сообщения, перезапуск не нужен. Файл переживает обновление модуля.
{ "deliveryMode": "personal" }
| режим | кто что видит |
|---|
personal (умолчание) | каждый видит только свои вопросы и ответы |
broadcast | оба видят весь обмен, чужое помечено «адресовано не вам» |
owner-all | владелец видит всё, координатор только своё |
🔴 Умолчание выбрано самым тихим намеренно: испорченный или недоступный файл настроек НЕ должен
внезапно раскрыть переписку в чужой канал. Ошибка чтения = personal, и о ней говорится в журнал.
Копируются и вопросы, и ответы: половина разговора без второй половины нечитаема.
1.2.0 — отправка переживает сбой сети
🔴 Отправка теперь повторяется. До этой версии одна неудачная попытка теряла сообщение
НАВСЕГДА и не оставляла следа — снаружи это неотличимо от «агент промолчал».
Поймано числами: на нашей машине fetch failed случается 6-10 раз в час. Копия вопроса не
дошла, а соседняя отправка четырьмя секундами позже прошла — то есть терялось не по логике, а по
случайности. Мы к тому моменту полдня искали причину молчания агента в коде, в настройках и в
чужих ботах.
Как сделано: три попытки с растущей паузой (0.4 с, 0.8 с), в журнал пишется, с какой попытки
удалось. Повторяются только сетевые сбои; отказ Telegram по существу (нет прав, чат не найден,
пустой текст) не повторяется — он воспроизведётся. Длинный опрос намеренно оставлен без повторов:
у него свой цикл, повтор лишь задержал бы следующий заход.
Урок общий: канал доставки без повтора — это тихая потеря. Отказ, о котором никто не узнал,
дороже отказа громкого: его невозможно даже сосчитать.
1.3.0 — постановка цели прямо из канала
🔴 Зачем это вообще нужно. Если цикл агента ведёт внешний движок — например, агент работает
через подписку, а не через родной цикл платформы, — родные инструменты платформы до модели не
доходят вовсе. Доходит только то, что имеет форму сообщения. Значит поставить агенту цель изнутри
разговора нельзя: руками в веб-интерфейсе можно, а из чата нет. Здесь мы даём постановку из любого
канала: сервису целей не нужен инструмент модели, он доступен коду плагина напрямую.
Команды одинаковы в обоих каналах:
/goal показать состояние цели
/goal <замысел> поставить цель
/goal stop снять цель
Кто вправе — двумя отдельными списками, оба по умолчанию ПУСТЫЕ, то есть команда отказывает
всем. Право не наследуется от allowedUsers сознательно: «с кем я разговариваю» и «кто вправе
завести дорогой автономный цикл» — разные вопросы, а в allowedUsers завтра добавят гостя.
goalUsers: [123456789] # из Telegram — по числовому id
goalA2ASenders: ["coordinator"] # из служебного канала — по имени в заголовке
🔴 Чем за это платят, сказать обязано само описание. Проверки платформы
(currentInitiator, hasDirectHumanInput, права на корневого агента) живут в её инструменте целей
и на этом пути не участвуют. Плагин не обходит политику — он пишет её заново, и две строки выше
есть вся политика целиком. Ставя goalUsers, вы соглашаетесь именно на это.
Счётчик постановок: не более трёх за скользящий час на канал. Предел раундов у платформы стоит
на ЦЕЛЬ, а не на агента: новая цель — новый счётчик, и поток целей ничем не ограничен. А каталог
входящих принадлежит самому агенту, то есть в автономном раунде он способен положить себе файл
/goal … и продлить цикл собственными руками.
🔴 Где счётчик НЕ применяется — назвать важнее, чем сам счётчик. Он защищает от РАЗГОНА:
от петли, в которую механизм вошёл сам, от ошибки в коде, от лавины повторов. Он не защищает от
намеренного действия агента с правами администратора: такой агент снимет и счётчик, и предел
раундов одной командой. Изоляция здесь поведенческая, технической границы против самого агента
не существует. Настоящий сторож живёт снаружи машины. Снятию цели (/goal stop) счётчик не мешает
никогда: механизм, включаемый снаружи и невыключаемый, хуже отсутствия механизма.
Заголовок отправителя — учёт, а не защита. Служебный канал файловый, и вид источника получает
любое сообщение в нём; правом «всем, кто пишет в канал» вы раздали бы дорогие циклы кому попало.
Поэтому отправитель называет себя первой строкой файла:
From: coordinator
/goal разобрать вчерашние отказы и доложить
Имя подделает любой, кто может положить файл в каталог входящих. Настоящая граница здесь — права
ОС на этот каталог. Нужна проверка сильнее самоназвания — это общий секрет в файле с правами, и
заводить его надо отдельным решением, а не походя.
Об исходе цели плагин сообщает сам. Доводчик платформы при упоре в предел раундов переводит
цель в blocked — и делает это молча: снаружи автономный цикл просто перестаёт просыпаться, что
неотличимо от поломки. Плагин говорит об исходе ровно один раз и в тот канал, из которого цель
ставили.
Служебный маркер цели вырезается из всего, что уходит наружу. Его дело — остановить цикл, а не
попасть человеку в чат. Вырезается маркер последней строки; маркер в середине текста оставлен
видимым нарочно — он и так отвергнут, и пусть будет заметен читателю.
Ожидание сервисов: где оно есть и где его нет — решение по каждому месту
ctx.get() возвращает undefined молча, пока волокно поставщика не активно. «Сервиса нет в
сборке» и «сервис ещё поднимается» приходят одним и тем же значением — и код, написанный как
const x = ctx.get('x'); if (x), тихо уходит по ветке «этой возможности просто нет».
| место | ожидание | почему |
|---|
| заведение агента (пресет, модель, персистенция) | есть, до 30 с | идёт вплотную к подъёму платформы, отказ молчалив и разрушителен |
/goal → сервисы целей, маркера, моста | есть, коротко | ответ «сервис не подключён» был бы диагнозом, которого код поставить не может; но на том конце живой собеседник, и полминуты молчания в чате читаются как «бот умер» |
| вырезание маркера из исходящего | нет | место синхронное, а отказ не молчалив: маркер уедет в чат видимым текстом |
| сообщение об исходе цели | нет | сюда попадают только сессии, где цель уже поставлена, — значит пустота означает не «ещё не поднялся», а «сервис пропал», и это надо сказать, а не переждать |
Замечено на живом: порядок готовности волокон меняется от подъёма к подъёму. В одном подъёме
ждала фабрика агентов, в другом — набор пресета, причём гонка была в обоих. Значит судить о гонке
по соседнему сервису нельзя, и ожидание ставится по устройству места, а не по тому, где однажды
видели примету.
Чего эта версия НЕ делает
- не даёт модели инструмент постановки цели — команда приходит от человека или от координатора
через канал, модель её не вызывает и в заголовке запроса плагин невидим;
- не заменяет права платформы — она их здесь не применяет вовсе (см. выше);
- не защищает от агента с правами администратора — вся защита поведенческая;
- не проверяет подлинность отправителя в служебном канале — только самоназвание;
- не переносит цель через перезапуск процесса: счётчик постановок обнуляется, а связь
«сессия → куда сообщить об исходе» живёт в памяти процесса. Сама цель хранится платформой и
переживает перезапуск, а вот сообщение об её исходе после перезапуска не придёт.