dsh-telegram-multiagent 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/src/index.js +104 -13
package/README.md CHANGED
@@ -50,7 +50,7 @@ see "Where to put the row" below:
50
50
  | `a2aSession` | no | Session id used for that channel. Default `a2a`. |
51
51
  | `transcribeCommand` | no | External command for voice messages: `<cmd> <audio-file> auto` → transcript on stdout. Omit and voice is politely refused. |
52
52
 
53
- ## Four things that cost us a day
53
+ ## Five things that cost us a day
54
54
 
55
55
  Each of these fails **while looking like success**. They are commented inline in the source; this is
56
56
  the short version.
@@ -74,6 +74,17 @@ session id makes the persistence layer abort *every* turn with an id-collision e
74
74
  "accepted the message and went quiet" — the turn honestly starts and dies in milliseconds. It works
75
75
  until the first restart, which is what makes it nasty. Use `resume()` when the session exists.
76
76
 
77
+ **5. `ctx.get()` returns nothing *quietly* while a service is still starting.** cordis hands back
78
+ `undefined` without throwing until the provider's fiber is active, so "this service is not in the
79
+ build" and "this service is thirty milliseconds away" arrive as the same value. Code written as
80
+ `const x = ctx.get('x'); if (x) {...}` then takes the "feature absent" branch and says nothing — the
81
+ agent is assembled, answers, and never mentions what it lost. This bit us three times in three days:
82
+ the agent factory (3), session persistence (4, the whole resume block was skipped), and the agent
83
+ preset — where a neighbouring agent's toolset dropped from 33 tools to 3 with no error and no log
84
+ line. Wait for the service with a stated deadline, and when the deadline passes, say so *and name
85
+ the consequence*. One helper for all of them: a fix applied to the instance instead of the class
86
+ guarantees a relapse, and the relapse looks like a new illness.
87
+
77
88
  ## Where to put the row
78
89
 
79
90
  In the `web` profile the common-plane tools are **disabled on purpose**; the toolset comes from the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-telegram-multiagent",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Telegram channel for DeepSeek Harness — one shared module serving several agents, with unforgeable sender marks, merged owner+coordinator memory, and a delivery mode you switch in a file outside the code.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -123,7 +123,7 @@ export function apply(ctx, config = {}) {
123
123
  // улетит в чат владельца. Ключ — сессия, значение — 'a2a' или 'tg'.
124
124
  const lastOrigin = new Map();
125
125
 
126
- // 🔴 СЛИЯНИЕ КАНАЛОВ В ОДНУ ПАМЯТЬ (20.08.2026, решение Александра).
126
+ // 🔴 СЛИЯНИЕ КАНАЛОВ В ОДНУ ПАМЯТЬ (20.08.2026, решение владельца).
127
127
  // mergeChatIntoA2A: <идентификатор чата> — сообщения этого чата попадают НЕ в
128
128
  // свою сессию `telegram-<чат>`, а в служебную. Тогда владелец и координатор
129
129
  // говорят с ОДНИМ агентом и одной памятью разговора, а различает он их по
@@ -134,14 +134,14 @@ export function apply(ctx, config = {}) {
134
134
  // Куда отвечать в мессенджер, если сессия общая: sessionId → числовой чат.
135
135
  const tgChatFor = new Map();
136
136
 
137
- // 🔴 КОМУ АДРЕСОВАН ОТВЕТ (21.08.2026, задача Александра).
137
+ // 🔴 КОМУ АДРЕСОВАН ОТВЕТ (21.08.2026, задача владельца).
138
138
  // Пометка на ВХОДЕ решает «кто спросил», но не решает «кому отвечено»: когда в
139
139
  // одной памяти сидят двое, ответ без адреса читается обоими как свой. И хуже:
140
140
  // маршрут «отвечаем последнему спросившему» ломается, если второй вопрос
141
141
  // пришёл, пока первый ещё считается, — ответ уедет не тому.
142
142
  // Поэтому происхождение привязано к ХОДУ, а не к последнему сообщению: на
143
143
  // входе кладём в очередь, на turn/start снимаем, ответ метим тем, что сняли.
144
- // ── НАСТРОЙКА ДОСТАВКИ, ОТДЕЛЬНЫМ ФАЙЛОМ (21.08.2026, задача Александра)
144
+ // ── НАСТРОЙКА ДОСТАВКИ, ОТДЕЛЬНЫМ ФАЙЛОМ (21.08.2026, задача владельца)
145
145
  //
146
146
  // Файл живёт ВНЕ модуля и переживает его обновление: код можно переписать,
147
147
  // перевыпустить, поставить заново — настройка останется. Читается на лету,
@@ -264,6 +264,46 @@ export function apply(ctx, config = {}) {
264
264
  throw new Error(`фабрика агентов не появилась за ${tries} с`);
265
265
  }
266
266
 
267
+ /**
268
+ * Дождаться сервиса платформы, не заперев канал навсегда.
269
+ *
270
+ * 🔴 ctx.get() МОЛЧИТ, КОГДА СЕРВИСА ЕЩЁ НЕТ, и это опаснее исключения.
271
+ * cordis возвращает undefined БЕЗ ошибки, пока волокно поставщика не активно
272
+ * (ReflectService._getImpl: `if (strict && impl.fiber.state !== 2) return;`).
273
+ * Значит «сервиса нет в сборке» и «сервис ещё поднимается» приходят одним и
274
+ * тем же значением, и код, написанный как `const x = ctx.get('x'); if (x)`,
275
+ * тихо уходит по ветке «этой возможности просто нет». Снаружи это выглядит
276
+ * штатной работой: агент собирается, отвечает и молчит о том, чего лишился.
277
+ *
278
+ * Поймано трижды за три дня, каждый раз дороже предыдущего:
279
+ * 19.08.2026 — фабрика агентов (см. withFactoryRetry выше);
280
+ * 22.08.2026 — sessionPersistence: блок продолжения истории пропускался
281
+ * целиком, агент создавался поверх существующего журнала, и
282
+ * КАЖДЫЙ ход умирал с «id collision» за считаные миллисекунды;
283
+ * 22.08.2026 — agentPresets: набор инструментов у модели упал с 33 до 3,
284
+ * без ошибки и без строки в журнале.
285
+ * Общий вывод, ради которого функция ОДНА, а не три: лечение, применённое к
286
+ * экземпляру, а не ко всем местам класса, гарантирует рецидив.
287
+ *
288
+ * ГРАНИЦА ОЖИДАНИЯ — назвать обязательно, иначе защита сама станет отказом:
289
+ * ждём не дольше `tries` секунд и только при заведении агента. Не дождались —
290
+ * говорим ГРОМКО, называем последствие и идём дальше. Канал не запирается там,
291
+ * где сервиса в сборке нет вовсе (это законная сборка, а не поломка).
292
+ */
293
+ async function waitService(name, tag, consequence, tries = 30) {
294
+ for (let i = 0; i < tries; i++) {
295
+ const found = ctx.get(name);
296
+ if (found) {
297
+ if (i > 0) log(`${tag} сервис ${name} появился через ${i} с ожидания`);
298
+ return found;
299
+ }
300
+ if (i === 0) log(`${tag} сервис ${name} ещё не активен — жду платформу`);
301
+ await new Promise((r) => setTimeout(r, 1000));
302
+ }
303
+ log(`${tag} 🔴 сервис ${name} не появился за ${tries} с — ${consequence}`);
304
+ return undefined;
305
+ }
306
+
267
307
  async function agentFor(chatId) {
268
308
  const key = String(chatId);
269
309
  const found = chats.get(key);
@@ -275,9 +315,11 @@ export function apply(ctx, config = {}) {
275
315
  // в запрос к модели не попадает поле tools вообще, и агент физически не
276
316
  // может вызвать ни один инструмент, хотя они смонтированы (проверено
277
317
  // 2026-08-18 по событию request/header в журнале сессии).
278
- const presets = ctx.get('agentPresets');
318
+ const presets = await waitService('agentPresets', `[${key}]`,
319
+ 'агент будет собран БЕЗ ИНСТРУМЕНТОВ');
279
320
  const presetId = presets ? (await presets.resolve(config.preset)).id : undefined;
280
- const defaultModel = ctx.get('agentDefaultModel');
321
+ const defaultModel = await waitService('agentDefaultModel', `[${key}]`,
322
+ 'модель берётся из настроек плагина, а не из выбора платформы');
281
323
  const selection = defaultModel?.currentSelection?.() ?? {
282
324
  provider: config.provider,
283
325
  model: config.model,
@@ -299,16 +341,36 @@ export function apply(ctx, config = {}) {
299
341
  // события turn/end (раньше плагин печатал только тип события).
300
342
  // Правильный путь — ctx.agents.resume(): «загрузить сохранённую сессию и
301
343
  // продолжить агента на ней» (dsh-agent/lib/index.js, resume()).
302
- const persistence = ctx.get('sessionPersistence');
344
+ const persistence = await waitService('sessionPersistence', `[${key}]`,
345
+ 'сохранённую сессию продолжить нечем, история будет потеряна');
303
346
  let handle;
304
347
  if (persistence) {
305
- let onDisk = false;
348
+ let known;
306
349
  try {
307
- onDisk = (await persistence.list()).some((h) => h.id === sessionId);
350
+ known = (await persistence.list()).map((h) => h.id);
308
351
  } catch (e) {
309
- log(`не смогла перечислить сохранённые сессии: ${e?.message ?? e}`);
352
+ log(`🔴 [${key}] не смог перечислить сохранённые сессии: ${e?.message ?? e}`);
353
+ }
354
+ // 🔴 УХОДИТЬ В create() МОЛЧА НЕЛЬЗЯ — ЭТО ВТОРОЙ ТИХИЙ ПУТЬ В ЭТОЙ ЖЕ
355
+ // ФУНКЦИИ. Снаружи он неотличим от честного первого запуска, а на деле
356
+ // означает create() поверх существующего журнала и «id collision» на
357
+ // каждом ходе. Поэтому список сообщаем ЧИСЛОМ и именами.
358
+ if (known !== undefined && !known.includes(sessionId)) {
359
+ // Пустой список — честный первый запуск, тревожить нечем. Тревога там,
360
+ // где сохранённые сессии ЕСТЬ, а нашей среди них нет.
361
+ if (known.length === 0) log(`[${key}] сохранённых сессий на диске нет — ${sessionId} заводится впервые`);
362
+ else {
363
+ // ⚠️, а не 🔴, СОЗНАТЕЛЬНО: по одному списку два исхода неразличимы —
364
+ // либо это первый разговор в новом чате (законно и обычно), либо
365
+ // журнал сессии потерян (беда). Отличить их отсюда нечем, поэтому
366
+ // строка называет ФАКТЫ и оба прочтения, а не ставит диагноз.
367
+ // Кричать 🔴 на законном событии — это защита, которая приучает
368
+ // не читать журнал.
369
+ const spisok = known.slice(0, 12).join(', ') + (known.length > 12 ? `, …и ещё ${known.length - 12}` : '');
370
+ log(`⚠️ [${key}] на диске ${known.length} сохранённых сессий, ${sessionId} среди них НЕТ: либо это первый разговор в чате, либо история потеряна. Продолжать нечего, создаю заново. Найдено: ${spisok}`);
371
+ }
310
372
  }
311
- if (onDisk) {
373
+ if (known !== undefined && known.includes(sessionId)) {
312
374
  try {
313
375
  handle = await ctx.agents.resume({
314
376
  resumeSessionId: sessionId,
@@ -602,14 +664,43 @@ export function apply(ctx, config = {}) {
602
664
  const A2A_OUT = A2A_DIR ? path.join(A2A_DIR, 'out') : null;
603
665
  const A2A_CHAT = config.a2aSession || 'a2a'; // отдельная сессия, не смешивается с Telegram
604
666
 
667
+ // Берём простые текстовые форматы. Всё остальное НЕ берём — но и не
668
+ // проглатываем: имя отвергнутого файла называем в журнале. Поймано
669
+ // 21.08.2026: письмо с расширением .md пролежало во входящих 26 минут,
670
+ // и снаружи это выглядело как «агент не отвечает».
671
+ const A2A_EXT = ['.txt', '.md'];
672
+ // ГРАНИЦА СТОРОЖА: о каждом имени сообщаем ОДИН раз — опрос идёт
673
+ // каждые ~25 секунд, и повтор превратил бы защиту в шум. Файл убрали —
674
+ // имя забываем, положат снова — сообщим снова.
675
+ const a2aReported = new Set();
676
+
605
677
  async function pollA2A() {
606
678
  if (!A2A_DIR) return;
607
- let files = [];
679
+ let all = [];
608
680
  try {
609
681
  fs.mkdirSync(A2A_IN, { recursive: true });
610
682
  fs.mkdirSync(A2A_OUT, { recursive: true });
611
- files = fs.readdirSync(A2A_IN).filter((f) => f.endsWith('.txt')).sort();
612
- } catch { return; }
683
+ all = fs.readdirSync(A2A_IN).sort();
684
+ } catch (e) {
685
+ // Раньше здесь стояло `catch { return; }`: каталог мог быть недоступен,
686
+ // а канал молча не работал вовсе. Одна строка на каждую причину.
687
+ const key = `DIR:${e?.code ?? 'ERR'}`;
688
+ if (!a2aReported.has(key)) {
689
+ a2aReported.add(key);
690
+ log(`[a2a] \u{1F534} каталог входящих недоступен (${A2A_IN}): ${e?.message ?? e}`);
691
+ }
692
+ return;
693
+ }
694
+ const files = all.filter((f) => A2A_EXT.some((x) => f.endsWith(x)));
695
+ for (const f of all) {
696
+ if (files.includes(f) || a2aReported.has(f)) continue;
697
+ a2aReported.add(f);
698
+ log(`[a2a] \u{1F534} НЕ ВЗЯТ файл ${f}: беру только ${A2A_EXT.join(', ')}. `
699
+ + `Он так и будет лежать во входящих — переименуй отправителю`);
700
+ }
701
+ for (const name of [...a2aReported]) {
702
+ if (!name.startsWith('DIR:') && !all.includes(name)) a2aReported.delete(name);
703
+ }
613
704
  for (const f of files) {
614
705
  const full = path.join(A2A_IN, f);
615
706
  let text = '';