dsh-telegram-multiagent 1.2.3 → 1.4.0
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.
- package/README.md +244 -106
- package/cordis.patch.yml +11 -0
- package/package.json +8 -3
- package/src/index.js +536 -44
- package/test/extract.mjs +44 -0
- package/test/stend-imena.mjs +133 -0
- package/test/stend-yashchika-shiny.mjs +184 -0
- package/test/test-goal-control.mjs +224 -0
package/src/index.js
CHANGED
|
@@ -99,6 +99,10 @@ export function apply(ctx, config = {}) {
|
|
|
99
99
|
// Имя агента — только для журнала: при нескольких ботах на машине нужно
|
|
100
100
|
// видеть, чья строка. К логике отношения не имеет.
|
|
101
101
|
const who = config.agentName || 'telegram';
|
|
102
|
+
// 🔴 Отдельная переменная, а НЕ повторное использование `who`: ниже, в ветке
|
|
103
|
+
// отказа чужому, `who` затенена локальной `const who = msg.from ?? {}`, и
|
|
104
|
+
// подстановка дала бы «[object Object]» вместо имени. Форма взята дословно
|
|
105
|
+
// из опубликованного 1.3.0, чтобы места не разъезжались при следующей правке.
|
|
102
106
|
const who_ = config.agentName || 'агент'; // для текстов, видимых собеседнику
|
|
103
107
|
const log = (m) => console.error(`[${who}] ${m}`);
|
|
104
108
|
|
|
@@ -123,7 +127,7 @@ export function apply(ctx, config = {}) {
|
|
|
123
127
|
// улетит в чат владельца. Ключ — сессия, значение — 'a2a' или 'tg'.
|
|
124
128
|
const lastOrigin = new Map();
|
|
125
129
|
|
|
126
|
-
// 🔴 СЛИЯНИЕ КАНАЛОВ В ОДНУ ПАМЯТЬ (20.08.2026, решение владельца).
|
|
130
|
+
// 🔴 СЛИЯНИЕ КАНАЛОВ В ОДНУ ПАМЯТЬ (20.08.2026, решение владельца машины).
|
|
127
131
|
// mergeChatIntoA2A: <идентификатор чата> — сообщения этого чата попадают НЕ в
|
|
128
132
|
// свою сессию `telegram-<чат>`, а в служебную. Тогда владелец и координатор
|
|
129
133
|
// говорят с ОДНИМ агентом и одной памятью разговора, а различает он их по
|
|
@@ -134,14 +138,14 @@ export function apply(ctx, config = {}) {
|
|
|
134
138
|
// Куда отвечать в мессенджер, если сессия общая: sessionId → числовой чат.
|
|
135
139
|
const tgChatFor = new Map();
|
|
136
140
|
|
|
137
|
-
// 🔴 КОМУ АДРЕСОВАН ОТВЕТ (21.08.2026, задача владельца).
|
|
141
|
+
// 🔴 КОМУ АДРЕСОВАН ОТВЕТ (21.08.2026, задача владельца машины).
|
|
138
142
|
// Пометка на ВХОДЕ решает «кто спросил», но не решает «кому отвечено»: когда в
|
|
139
143
|
// одной памяти сидят двое, ответ без адреса читается обоими как свой. И хуже:
|
|
140
144
|
// маршрут «отвечаем последнему спросившему» ломается, если второй вопрос
|
|
141
145
|
// пришёл, пока первый ещё считается, — ответ уедет не тому.
|
|
142
146
|
// Поэтому происхождение привязано к ХОДУ, а не к последнему сообщению: на
|
|
143
147
|
// входе кладём в очередь, на turn/start снимаем, ответ метим тем, что сняли.
|
|
144
|
-
// ── НАСТРОЙКА ДОСТАВКИ, ОТДЕЛЬНЫМ ФАЙЛОМ (21.08.2026, задача владельца)
|
|
148
|
+
// ── НАСТРОЙКА ДОСТАВКИ, ОТДЕЛЬНЫМ ФАЙЛОМ (21.08.2026, задача владельца машины)
|
|
145
149
|
//
|
|
146
150
|
// Файл живёт ВНЕ модуля и переживает его обновление: код можно переписать,
|
|
147
151
|
// перевыпустить, поставить заново — настройка останется. Читается на лету,
|
|
@@ -271,24 +275,19 @@ export function apply(ctx, config = {}) {
|
|
|
271
275
|
* cordis возвращает undefined БЕЗ ошибки, пока волокно поставщика не активно
|
|
272
276
|
* (ReflectService._getImpl: `if (strict && impl.fiber.state !== 2) return;`).
|
|
273
277
|
* Значит «сервиса нет в сборке» и «сервис ещё поднимается» приходят одним и
|
|
274
|
-
* тем же значением, и
|
|
275
|
-
*
|
|
276
|
-
* штатной работой: агент собирается, отвечает и молчит о том, чего лишился.
|
|
278
|
+
* тем же значением, и код вида `const x = ctx.get('x'); if (x)` тихо уходит по
|
|
279
|
+
* ветке «этой возможности просто нет». Снаружи это штатная работа.
|
|
277
280
|
*
|
|
278
|
-
* Поймано трижды за три
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
* без ошибки и без строки в журнале.
|
|
285
|
-
* Общий вывод, ради которого функция ОДНА, а не три: лечение, применённое к
|
|
286
|
-
* экземпляру, а не ко всем местам класса, гарантирует рецидив.
|
|
281
|
+
* Поймано трижды за три дня: 19.08 фабрика агентов (withFactoryRetry выше),
|
|
282
|
+
* 22.08 sessionPersistence (блок продолжения пропускался целиком, агент
|
|
283
|
+
* создавался поверх существующего журнала, каждый ход умирал с «id collision»),
|
|
284
|
+
* 22.08 agentPresets (набор инструментов у соседа упал с 33 до 3 без единой
|
|
285
|
+
* строки в журнале). Лечение экземпляра вместо класса гарантирует рецидив —
|
|
286
|
+
* поэтому функция ОДНА и форма у неё одна с опубликованным пакетом 1.2.3.
|
|
287
287
|
*
|
|
288
|
-
* ГРАНИЦА
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
* где сервиса в сборке нет вовсе (это законная сборка, а не поломка).
|
|
288
|
+
* ГРАНИЦА ОЖИДАНИЯ: не дольше `tries` секунд и только при заведении агента.
|
|
289
|
+
* Не дождались — говорим ГРОМКО, называем последствие и идём дальше. Канал не
|
|
290
|
+
* запирается там, где сервиса в сборке нет вовсе: это законная сборка.
|
|
292
291
|
*/
|
|
293
292
|
async function waitService(name, tag, consequence, tries = 30) {
|
|
294
293
|
for (let i = 0; i < tries; i++) {
|
|
@@ -304,6 +303,40 @@ export function apply(ctx, config = {}) {
|
|
|
304
303
|
return undefined;
|
|
305
304
|
}
|
|
306
305
|
|
|
306
|
+
/**
|
|
307
|
+
* 🔴 РЕШЕНИЕ ПО КАЖДОМУ ctx.get() В ЭТОМ ФАЙЛЕ (23.08.2026, уточнено при
|
|
308
|
+
* сведении с копией соседа). Проверять надо КАЖДЫЙ, а не тот, на котором
|
|
309
|
+
* обожглись, — иначе рецидив.
|
|
310
|
+
*
|
|
311
|
+
* ПРИЗНАК: ожидание нужно там, где место может сработать ДО подъёма сервиса
|
|
312
|
+
* И отказ при этом МОЛЧАЛИВ ИЛИ ЛОЖЕН. Второе добавлено сознательно: раньше
|
|
313
|
+
* здесь было записано «отказ громкий — значит ждать не надо», и это было
|
|
314
|
+
* ошибкой. Громкий отказ «сервис не подключён» — это ДИАГНОЗ, которого код
|
|
315
|
+
* поставить не может: undefined одинаково означает и «нет в сборке», и «ещё
|
|
316
|
+
* поднимается». Механизм не вправе утверждать то, чего не в силах установить.
|
|
317
|
+
*
|
|
318
|
+
* И отдельно, доказано на живом 23.08: порядок готовности волокон МЕНЯЕТСЯ
|
|
319
|
+
* от подъёма к подъёму — в одном ждала фабрика агентов, в другом набор
|
|
320
|
+
* пресета, а гонка была в обоих. Значит судить о гонке по соседнему сервису
|
|
321
|
+
* нельзя, ожидание ставится по устройству места, а не по примете.
|
|
322
|
+
*
|
|
323
|
+
* 1) agentFor → agentPresets, agentDefaultModel, sessionPersistence:
|
|
324
|
+
* ОЖИДАНИЕ ЕСТЬ, предел 30 с — заведение агента идёт вплотную к подъёму
|
|
325
|
+
* платформы, отказ молчалив и разрушителен.
|
|
326
|
+
* 2) goalCommand → goals, goalMarker, mcpBridge: ОЖИДАНИЕ ЕСТЬ, предел
|
|
327
|
+
* короткий (GOAL_WAIT_TRIES) — на том конце живой собеседник.
|
|
328
|
+
* 3) stripGoalMarker → goalMarker: ОЖИДАНИЯ НЕТ намеренно. Место
|
|
329
|
+
* СИНХРОННОЕ (обработчик события сессии не async), а отказ не молчалив:
|
|
330
|
+
* маркер уезжает в чат видимым текстом.
|
|
331
|
+
* 4) обработчик goal/change → goals: ОЖИДАНИЯ НЕТ намеренно. Сюда попадают
|
|
332
|
+
* только сессии, где цель уже поставлена через goalCommand, а тот без
|
|
333
|
+
* сервиса целей не проходит. Значит пустота означает не «ещё не
|
|
334
|
+
* поднялся», а «сервис ПРОПАЛ», и это надо сказать, а не переждать.
|
|
335
|
+
*/
|
|
336
|
+
// Сколько секунд ждать сервис на пути КОМАНДЫ (не на заведении агента).
|
|
337
|
+
// Пять, а не тридцать: на том конце живой собеседник, см. goalCommand.
|
|
338
|
+
const GOAL_WAIT_TRIES = 5;
|
|
339
|
+
|
|
307
340
|
async function agentFor(chatId) {
|
|
308
341
|
const key = String(chatId);
|
|
309
342
|
const found = chats.get(key);
|
|
@@ -327,6 +360,7 @@ export function apply(ctx, config = {}) {
|
|
|
327
360
|
const setupFn = async (agentCtx) => {
|
|
328
361
|
platform.installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
329
362
|
if (presets && presetId) await presets.mount(agentCtx, presetId);
|
|
363
|
+
else log(`[${key}] 🔴 пресет НЕ смонтирован (presets=${!!presets}, presetId=${presetId})`);
|
|
330
364
|
};
|
|
331
365
|
|
|
332
366
|
// 🔴 СЕССИЮ, КОТОРАЯ УЖЕ ЛЕЖИТ НА ДИСКЕ, НАДО ПРОДОЛЖАТЬ, А НЕ СОЗДАВАТЬ ЗАНОВО.
|
|
@@ -341,6 +375,17 @@ export function apply(ctx, config = {}) {
|
|
|
341
375
|
// события turn/end (раньше плагин печатал только тип события).
|
|
342
376
|
// Правильный путь — ctx.agents.resume(): «загрузить сохранённую сессию и
|
|
343
377
|
// продолжить агента на ней» (dsh-agent/lib/index.js, resume()).
|
|
378
|
+
// 🔴 СЕРВИС ПЕРСИСТЕНЦИИ ПОЯВЛЯЕТСЯ ПОЗЖЕ КАНАЛА — та же болезнь, что у фабрики
|
|
379
|
+
// агентов выше, и тот же почерк: ctx.get() отдаёт undefined ТИХО, пока волокно
|
|
380
|
+
// поставщика не активно (cordis, ReflectService._getImpl:
|
|
381
|
+
// `if (strict && impl.fiber.state !== 2) return;`),
|
|
382
|
+
// исключения при этом нет. Без ожидания весь блок продолжения молча
|
|
383
|
+
// пропускался, агент создавался заново поверх существующего журнала, и КАЖДЫЙ
|
|
384
|
+
// ход умирал с «id collision». Поймано 22.08.2026: подъём 18:53 не напечатал
|
|
385
|
+
// ни строки о продолжении, ни об отказе — молчание и было ответом.
|
|
386
|
+
// ГРАНИЦА ОЖИДАНИЯ: ждём не дольше 30 с и только здесь, при заведении агента.
|
|
387
|
+
// Не дождались — говорим об этом громко и идём создавать, иначе канал запрётся
|
|
388
|
+
// навсегда там, где персистенции в сборке просто нет.
|
|
344
389
|
const persistence = await waitService('sessionPersistence', `[${key}]`,
|
|
345
390
|
'сохранённую сессию продолжить нечем, история будет потеряна');
|
|
346
391
|
let handle;
|
|
@@ -349,15 +394,16 @@ export function apply(ctx, config = {}) {
|
|
|
349
394
|
try {
|
|
350
395
|
known = (await persistence.list()).map((h) => h.id);
|
|
351
396
|
} catch (e) {
|
|
352
|
-
log(`🔴 [${key}] не
|
|
397
|
+
log(`🔴 [${key}] не смогла перечислить сохранённые сессии: ${e?.message ?? e}`);
|
|
353
398
|
}
|
|
354
|
-
// 🔴 УХОДИТЬ В create() МОЛЧА
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
399
|
+
// 🔴 УХОДИТЬ В create() МОЛЧА НЕЛЬЗЯ. Снаружи это неотличимо от штатного
|
|
400
|
+
// первого запуска, а на деле означает потерю истории и id collision на
|
|
401
|
+
// каждом ходе. Поэтому отсутствие своей сессии в списке — громкая строка
|
|
402
|
+
// с числом найденных и их именами, а не тишина.
|
|
358
403
|
if (known !== undefined && !known.includes(sessionId)) {
|
|
359
|
-
// Пустой список — честный первый запуск, тревожить нечем.
|
|
360
|
-
// где сохранённые сессии ЕСТЬ, а нашей среди них
|
|
404
|
+
// Пустой список — это честный первый запуск, а не беда: тревожить нечем.
|
|
405
|
+
// Тревога начинается там, где сохранённые сессии ЕСТЬ, а нашей среди них
|
|
406
|
+
// нет: значит журнал на диске остался, и create() сейчас об него разобьётся.
|
|
361
407
|
if (known.length === 0) log(`[${key}] сохранённых сессий на диске нет — ${sessionId} заводится впервые`);
|
|
362
408
|
else {
|
|
363
409
|
// ⚠️, а не 🔴, СОЗНАТЕЛЬНО: по одному списку два исхода неразличимы —
|
|
@@ -367,7 +413,7 @@ export function apply(ctx, config = {}) {
|
|
|
367
413
|
// Кричать 🔴 на законном событии — это защита, которая приучает
|
|
368
414
|
// не читать журнал.
|
|
369
415
|
const spisok = known.slice(0, 12).join(', ') + (known.length > 12 ? `, …и ещё ${known.length - 12}` : '');
|
|
370
|
-
log(`⚠️ [${key}] на диске ${known.length} сохранённых сессий, ${sessionId} среди них НЕТ: либо это первый разговор в чате, либо история
|
|
416
|
+
log(`⚠️ [${key}] на диске ${known.length} сохранённых сессий, ${sessionId} среди них НЕТ: либо это первый разговор в чате, либо история потеряна — по одному списку не отличить. Продолжать нечего, создаю заново. Найдено: ${spisok}`);
|
|
371
417
|
}
|
|
372
418
|
}
|
|
373
419
|
if (known !== undefined && known.includes(sessionId)) {
|
|
@@ -441,11 +487,235 @@ export function apply(ctx, config = {}) {
|
|
|
441
487
|
}
|
|
442
488
|
}
|
|
443
489
|
|
|
490
|
+
// ── ПОСТАНОВКА ЦЕЛИ ИЗ КАНАЛА (21.08.2026, распоряжение главной).
|
|
491
|
+
//
|
|
492
|
+
// ЗАЧЕМ. На подписочном маршруте цикл ведёт движок, поэтому родные
|
|
493
|
+
// инструменты платформы (goal/jobs) до модели НЕ ДОХОДЯТ вовсе — доходит
|
|
494
|
+
// только то, что имеет форму СООБЩЕНИЯ. Значит поставить цель изнутри
|
|
495
|
+
// разговора нельзя, а руками в веб-интерфейсе — можно. Здесь мы даём
|
|
496
|
+
// постановку из любого канала: сервис целей доступен КОДУ модуля как
|
|
497
|
+
// ctx.get('goals'), инструмент модели для этого не нужен.
|
|
498
|
+
//
|
|
499
|
+
// КТО ВПРАВЕ. Отдельные настройки, обе по умолчанию ВЫКЛЮЧЕНЫ:
|
|
500
|
+
// goalUsers: [<числовые id>] — кто ставит цель из Telegram
|
|
501
|
+
// goalFromA2A: true|false — вправе ли служебный канал
|
|
502
|
+
// Право НЕ наследуется от allowedUsers сознательно: allowedUsers — это
|
|
503
|
+
// «с кем я вообще разговариваю», и завтра туда добавят гостя. Право завести
|
|
504
|
+
// дорогой автономный цикл шире права поговорить.
|
|
505
|
+
// Проверки платформы (currentInitiator, hasDirectHumanInput, roots) живут в
|
|
506
|
+
// tool-goal и на этом пути НЕ участвуют: мы не обходим политику, мы пишем её
|
|
507
|
+
// заново, и вот эти две строки — вся политика целиком.
|
|
508
|
+
const goalUsers = new Set((config.goalUsers ?? []).map(Number));
|
|
509
|
+
// 🔴 ПРАВО ИЗ СЛУЖЕБНОГО КАНАЛА — ПОИМЁННО, А НЕ «КАНАЛУ ЦЕЛИКОМ» (21.08.2026,
|
|
510
|
+
// поправка главной к собственному замыслу). Вид источника 'a2a' получает ЛЮБОЕ
|
|
511
|
+
// сообщение служебного канала, а канал на ферме общий — правом «всем, кто пишет
|
|
512
|
+
// в a2a» мы раздали бы возможность заводить дорогие циклы кому попало.
|
|
513
|
+
// Отправитель называет себя первой строкой файла: From: <инстанс>/<папка>
|
|
514
|
+
// Нет заголовка или имени нет в списке — отказ, и он произносится вслух.
|
|
515
|
+
const goalA2ASenders = new Set(config.goalA2ASenders ?? []);
|
|
516
|
+
// sessionId → { channel, chatId } — куда сообщить об исходе цели.
|
|
517
|
+
const goalOrigin = new Map();
|
|
518
|
+
// канал → метки времени постановок, для счётчика ниже.
|
|
519
|
+
const goalStarts = new Map();
|
|
520
|
+
|
|
521
|
+
// 🔴 СЧЁТЧИК ПОСТАНОВОК: не более трёх за скользящий час на канал.
|
|
522
|
+
//
|
|
523
|
+
// ОТ ЧЕГО ЗАЩИЩАЕТ: от СЛУЧАЙНОСТИ и РАЗГОНА — от петли, в которую механизм
|
|
524
|
+
// вошёл сам, от ошибки в коде, от лавины повторов. Предел раундов стоит на
|
|
525
|
+
// ЦЕЛЬ, а не на агента: новая цель — новый счётчик, и поток целей ничем не
|
|
526
|
+
// ограничен. Каталог входящих принадлежит самому агенту, то есть в автономном
|
|
527
|
+
// раунде он способен положить себе файл «/goal …» и продлить цикл.
|
|
528
|
+
//
|
|
529
|
+
// 🔴 ГДЕ НЕ ПРИМЕНЯЕТСЯ: он НЕ защищает от намеренного действия агента,
|
|
530
|
+
// у которого на этой машине полное sudo без пароля (проверено 21.08.2026:
|
|
531
|
+
// <агент> ALL NOPASSWD: ALL). Такой агент снимет и этот счётчик, и предел
|
|
532
|
+
// раундов одной командой. Технической границы против самого агента здесь
|
|
533
|
+
// не существует, изоляция ПОВЕДЕНЧЕСКАЯ. Настоящий сторож — снаружи, на
|
|
534
|
+
// другой машине, вне досягаемости этого процесса.
|
|
535
|
+
//
|
|
536
|
+
// ГРАНИЦЫ САМОГО СЧЁТЧИКА: снятию цели (/goal stop) он не мешает никогда —
|
|
537
|
+
// механизм, включаемый снаружи и невыключаемый, хуже отсутствия механизма;
|
|
538
|
+
// обнуляется перезапуском процесса; ничего не чинит сам, только отказывает.
|
|
539
|
+
const GOAL_MAX_PER_HOUR = 3;
|
|
540
|
+
function goalRateOk(channel) {
|
|
541
|
+
const now = Date.now();
|
|
542
|
+
const marks = (goalStarts.get(channel) ?? []).filter((t) => now - t < 3600000);
|
|
543
|
+
goalStarts.set(channel, marks);
|
|
544
|
+
return marks.length < GOAL_MAX_PER_HOUR;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 🔴 ПРИЗНАК СОБСТВЕННОЙ ИСПРАВНОСТИ ПЛАГИНА (22.08.2026, распоряжение главной).
|
|
548
|
+
// Права на постановку цели заголовком request/header НЕ проверяются в принципе:
|
|
549
|
+
// плагин не даёт модели ни одного инструмента и в заголовке невидим. Печатаем
|
|
550
|
+
// ЧИСЛА, а не «загрузился»: длина списка прав отличает верную настройку от
|
|
551
|
+
// пустого умолчания, при котором постановка отказывает молча и выглядит поломкой.
|
|
552
|
+
// Сами идентификаторы не печатаем — в журнал уходит устройство, а не список людей.
|
|
553
|
+
log(`[goal] смонтирован: goalUsers=${goalUsers.size} goalA2ASenders=${goalA2ASenders.size} `
|
|
554
|
+
+ `предел постановок=${GOAL_MAX_PER_HOUR}/час`);
|
|
555
|
+
|
|
556
|
+
async function sayTo(channel, chatId, text) {
|
|
557
|
+
if (channel === 'a2a') {
|
|
558
|
+
if (!A2A_OUT) { log('[goal] 🔴 ответ в служебный канал не ушёл: каталог обмена не задан'); return; }
|
|
559
|
+
try {
|
|
560
|
+
fs.mkdirSync(A2A_OUT, { recursive: true });
|
|
561
|
+
fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}-goal.txt`), text);
|
|
562
|
+
} catch (e) { log(`[goal] 🔴 ответ в служебный канал не ушёл: ${e?.message ?? e}`); }
|
|
563
|
+
} else {
|
|
564
|
+
try { await tg.send(chatId, text); } catch (e) { log(`[goal] 🔴 ответ в чат не ушёл: ${e?.message ?? e}`); }
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Отделить заголовок отправителя от текста служебного сообщения.
|
|
570
|
+
*
|
|
571
|
+
* 🔴 ЧЕСТНАЯ ГРАНИЦА, назвать её обязательно: это УЧЁТ, А НЕ ЗАЩИТА.
|
|
572
|
+
* Отправитель называет себя сам, и подделать имя может любой, кто способен
|
|
573
|
+
* положить файл в каталог входящих. Настоящая граница здесь — права ОС:
|
|
574
|
+
* дом агента открыт на проход только владельцу (drwxr-x---), поэтому писать
|
|
575
|
+
* во входящие могут лишь сам агент и тот, у кого есть sudo. Проверено
|
|
576
|
+
* опытом 21.08.2026: dsh и arg получают Permission denied и на чтение, и на
|
|
577
|
+
* запись. Владелец файла отправителя НЕ выдаёт вовсе — доставка кладёт файл
|
|
578
|
+
* от root и тут же делает chown на адресата.
|
|
579
|
+
* Нужна проверка сильнее самоназвания — это общий секрет в файле с правами,
|
|
580
|
+
* и заводить его нужно решением, а не походя.
|
|
581
|
+
*/
|
|
582
|
+
/**
|
|
583
|
+
* Убрать маркер цели из текста, уходящего наружу. Если плагин разбора не
|
|
584
|
+
* смонтирован — текст идёт как есть: маркер в чате некрасив, но безвреден,
|
|
585
|
+
* а молча глотать ответ из-за отсутствия служебного плагина нельзя.
|
|
586
|
+
*/
|
|
587
|
+
function stripGoalMarker(raw) {
|
|
588
|
+
const marker = ctx.get('goalMarker');
|
|
589
|
+
return marker ? marker.strip(raw) : raw;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function splitSender(raw) {
|
|
593
|
+
const nl = raw.indexOf('\n');
|
|
594
|
+
const head = (nl === -1 ? raw : raw.slice(0, nl)).trim();
|
|
595
|
+
const m = /^From:\s*(\S+)$/.exec(head);
|
|
596
|
+
if (!m) return { sender: undefined, text: raw };
|
|
597
|
+
return { sender: m[1], text: nl === -1 ? '' : raw.slice(nl + 1).trimStart() };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Разбор команды. Возвращает текст ответа; отправку делает вызывающий.
|
|
601
|
+
// Отказы называем вслух: молчаливый отказ здесь неотличим от «агент не понял».
|
|
602
|
+
async function goalCommand(text, channel, agent) {
|
|
603
|
+
const rest = text.slice('/goal'.length).trim();
|
|
604
|
+
// 🔴 ЖДЁМ СЕРВИС, А НЕ ОТКАЗЫВАЕМ СРАЗУ (23.08.2026, сведение с копией
|
|
605
|
+
// соседа — решение его, форма наша). Без ожидания ответ «не подключён» был
|
|
606
|
+
// бы диагнозом, которого код поставить не может: undefined одинаково
|
|
607
|
+
// означает и «нет в сборке», и «ещё поднимается». Предел короткий, а не
|
|
608
|
+
// 30 с как при заведении агента: на том конце ждёт живой собеседник, и
|
|
609
|
+
// полминуты молчания в чате читаются как «бот умер».
|
|
610
|
+
const goals = await waitService('goals', '[goal]',
|
|
611
|
+
'команда постановки цели невыполнима', GOAL_WAIT_TRIES);
|
|
612
|
+
if (!goals) return 'Сервис целей на этом агенте не подключён.';
|
|
613
|
+
let current;
|
|
614
|
+
try { current = goals.get(agent); }
|
|
615
|
+
catch (e) { log(`[goal] 🔴 состояние цели не прочитано: ${e?.message ?? e}`); return `Не смог прочитать состояние цели: ${e?.message ?? e}`; }
|
|
616
|
+
|
|
617
|
+
if (rest === '') {
|
|
618
|
+
if (!current) return 'Цели нет.';
|
|
619
|
+
return `Цель ${current.id}: ${current.phase}, раунд ${current.roundsStarted} из ${current.maxGoalRounds}.`
|
|
620
|
+
+ (current.blockedReason ? `\nПричина остановки: ${current.blockedReason}` : '')
|
|
621
|
+
+ `\nЗамысел: ${String(current.objective).slice(0, 200)}`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (rest === 'stop') {
|
|
625
|
+
// Снятие счётчиком НЕ ограничено — см. границы выше.
|
|
626
|
+
if (!current) return 'Цели нет, снимать нечего.';
|
|
627
|
+
try {
|
|
628
|
+
goals.clear(agent, { id: current.id, revision: current.revision });
|
|
629
|
+
goalOrigin.delete(String(agent.session?.id ?? ''));
|
|
630
|
+
log(`[goal] снята ${current.id} из ${channel}`);
|
|
631
|
+
return `Цель ${current.id} снята.`;
|
|
632
|
+
} catch (e) {
|
|
633
|
+
log(`[goal] 🔴 снятие не удалось: ${e?.message ?? e}`);
|
|
634
|
+
return `Не смог снять цель: ${e?.message ?? e}`;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Постановка. Предел раундов командой НЕ задаётся: он живёт в настройке
|
|
639
|
+
// (defaultMaxGoalRounds) и не должен подниматься тем же путём, каким
|
|
640
|
+
// запускается дорогой цикл.
|
|
641
|
+
if (!goalRateOk(channel)) {
|
|
642
|
+
log(`[goal] ОТКАЗ ${channel}: исчерпан предел ${GOAL_MAX_PER_HOUR} постановок за час`);
|
|
643
|
+
return `Предел ${GOAL_MAX_PER_HOUR} постановок цели за час исчерпан. Зовите человека.`;
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
let view = goals.create(agent, { objective: rest });
|
|
647
|
+
// 🔴 НАСТАВЛЕНИЕ ПРО МАРКЕР КЛАДЁМ В САМ ЗАМЫСЕЛ. Промпт автоматического
|
|
648
|
+
// раунда (renderGoalRoundPrompt) несёт только Objective и Round — ни
|
|
649
|
+
// идентификатора цели, ни ревизии в нём нет, а без них модель не сложит
|
|
650
|
+
// маркер. Идентификатор известен только после create, поэтому edit вторым
|
|
651
|
+
// шагом; edit поднимает ревизию на единицу, её и называем в наставлении.
|
|
652
|
+
const marker = await waitService('goalMarker', '[goal]',
|
|
653
|
+
'цель будет поставлена без наставления о маркере', GOAL_WAIT_TRIES);
|
|
654
|
+
if (marker) {
|
|
655
|
+
// 🔴 ИМЕНА ИНСТРУМЕНТОВ БЕРЁМ У МОСТА, А НЕ ПИШЕМ ПО ПАМЯТИ (22.08.2026).
|
|
656
|
+
// Протокол MCP добавляет к базовому имени приставку mcp__<сервер>__;
|
|
657
|
+
// наставление, называющее инструмент БЕЗ неё, отсылает модель к
|
|
658
|
+
// несуществующему имени, и выглядит это как «мост не работает».
|
|
659
|
+
// Нет моста — нет и имён: тогда наставление честно говорит, что
|
|
660
|
+
// инструментов нет, и остаётся только пометка.
|
|
661
|
+
const bridge = await waitService('mcpBridge', '[goal]',
|
|
662
|
+
'в наставлении останется только пометка, без имён инструментов', GOAL_WAIT_TRIES);
|
|
663
|
+
const goalTools = bridge?.modelToolName
|
|
664
|
+
? { update: bridge.modelToolName('update_goal'), get: bridge.modelToolName('get_goal') }
|
|
665
|
+
: undefined;
|
|
666
|
+
log(`[goal] наставление: ${goalTools ? `инструмент ${goalTools.update}, пометка запасной линией` : 'моста нет, только пометка'}`);
|
|
667
|
+
view = goals.edit(agent, { id: view.id, revision: view.revision },
|
|
668
|
+
{ objective: `${rest}\n\n${marker.guidance(view.id, view.revision + 1, goalTools)}` });
|
|
669
|
+
} else {
|
|
670
|
+
log('[goal] ⚠️ сервис goalMarker не смонтирован: цель поставлена, но закрыть её сама модель не сможет — цикл дойдёт до предела раундов');
|
|
671
|
+
}
|
|
672
|
+
goalStarts.get(channel).push(Date.now());
|
|
673
|
+
goalOrigin.set(String(agent.session?.id ?? ''), { channel, agent });
|
|
674
|
+
log(`[goal] поставлена ${view.id} из ${channel}, предел ${view.maxGoalRounds} раундов: ${rest.slice(0, 60)}`);
|
|
675
|
+
return `Цель ${view.id} поставлена, предел ${view.maxGoalRounds} раундов.`;
|
|
676
|
+
} catch (e) {
|
|
677
|
+
const code = e?.code ?? '';
|
|
678
|
+
log(`[goal] 🔴 постановка не удалась (${code}): ${e?.message ?? e}`);
|
|
679
|
+
if (code === 'GOAL_ALREADY_EXISTS') return 'Цель уже поставлена. Сначала /goal stop.';
|
|
680
|
+
return `Не смог поставить цель: ${e?.message ?? e}`;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
444
684
|
// ── ВЫХОД: события сессии → сообщения в Telegram
|
|
445
685
|
ctx.on('session/event', (session, event) => {
|
|
446
686
|
// 🔴 DEBUG 2026-08-18: все типы событий
|
|
447
687
|
log(`[event] type=${event.type} session.id=${session?.id} knownSessions=${[...sessionToChat.keys()].join('|')}`);
|
|
448
688
|
const sid = String(session?.id ?? '');
|
|
689
|
+
// 🔴 ИСХОД ЦЕЛИ СООБЩАЕМ САМИ. Доводчик при упоре в предел переводит цель в
|
|
690
|
+
// blocked — и делает это молча: снаружи автономный цикл просто перестаёт
|
|
691
|
+
// просыпаться. Молчаливый останов неотличим от поломки, поэтому говорим в
|
|
692
|
+
// тот канал, из которого цель ставили, и ровно один раз.
|
|
693
|
+
if (event.type === 'goal/change') {
|
|
694
|
+
const origin = goalOrigin.get(sid);
|
|
695
|
+
if (origin) {
|
|
696
|
+
try {
|
|
697
|
+
// 🔴 БЫЛО МОЛЧАЛИВО: `ctx.get('goals')?.get(...)` при отсутствии
|
|
698
|
+
// сервиса возвращал undefined, и сообщение об исходе цели просто не
|
|
699
|
+
// уходило — ровно то молчание, от которого этот блок и заведён.
|
|
700
|
+
// Сервис здесь обязан быть: goalOrigin заполняет только goalCommand,
|
|
701
|
+
// а он без ctx.get('goals') не проходит. Значит пустота означает, что
|
|
702
|
+
// сервис ПРОПАЛ, и это событие само по себе стоит строки.
|
|
703
|
+
const goalsSvc = ctx.get('goals');
|
|
704
|
+
if (!goalsSvc) {
|
|
705
|
+
log(`[goal] 🔴 сервис целей исчез — исход цели по сессии ${sid} не сообщён`);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const view = goalsSvc.get(origin.agent);
|
|
709
|
+
if (view && view.phase !== 'active') {
|
|
710
|
+
goalOrigin.delete(sid);
|
|
711
|
+
void sayTo(origin.channel, tgChatFor.get(sid) ?? sessionToChat.get(sid),
|
|
712
|
+
`Цель ${view.id}: ${view.phase}`
|
|
713
|
+
+ (view.blockedReason ? `, причина: ${view.blockedReason}` : '')
|
|
714
|
+
+ `. Раундов пройдено ${view.roundsStarted} из ${view.maxGoalRounds}.`);
|
|
715
|
+
}
|
|
716
|
+
} catch (e) { log(`[goal] исход цели не прочитан: ${e?.message ?? e}`); }
|
|
717
|
+
}
|
|
718
|
+
}
|
|
449
719
|
// 🔴 При слиянии sessionToChat указывает на КЛЮЧ служебной сессии, а не на
|
|
450
720
|
// числовой чат — отправка по нему молча не дойдёт. Настоящий чат берём из
|
|
451
721
|
// карты, заполняемой на входе из мессенджера.
|
|
@@ -478,25 +748,30 @@ export function apply(ctx, config = {}) {
|
|
|
478
748
|
}
|
|
479
749
|
} else if (event.type === 'assistant/message'
|
|
480
750
|
&& (turnAsk.get(sid)?.origin ?? lastOrigin.get(sid) ?? (chatId === A2A_CHAT ? 'a2a' : 'tg')) === 'a2a') {
|
|
481
|
-
// ответ
|
|
751
|
+
// ответ для Забавы — в файл, Telegram тут ни при чём
|
|
482
752
|
const blocks = event.data?.message?.content ?? [];
|
|
483
|
-
|
|
753
|
+
// 🔴 МАРКЕР ВЫРЕЗАЕМ ИЗ ВСЕГО, ЧТО УХОДИТ НАРУЖУ — из доставляемого
|
|
754
|
+
// текста и из копий. Он служебный: его дело остановить цикл, а не
|
|
755
|
+
// попасть человеку в чат. Вырезается только маркер последней строки;
|
|
756
|
+
// маркер в середине оставляем видимым нарочно — он и так отвергнут,
|
|
757
|
+
// и пусть будет заметен читателю.
|
|
758
|
+
const text = stripGoalMarker(blocks.filter((b) => b?.type === 'text').map((b) => b.text).join('\n')).trim();
|
|
484
759
|
if (text) {
|
|
485
760
|
try {
|
|
486
761
|
fs.mkdirSync(A2A_OUT, { recursive: true });
|
|
487
762
|
fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}.txt`), replyHeader(sid) + text);
|
|
488
763
|
{ const c = copyTo('a2a'); if (c) sendCopy(c, sid, text); }
|
|
489
|
-
log(`[a2a] ответ
|
|
764
|
+
log(`[a2a] ответ Забаве записан (${text.length} знаков)`);
|
|
490
765
|
} catch (e) { log(`[a2a] не смог записать ответ: ${e?.message ?? e}`); }
|
|
491
766
|
}
|
|
492
767
|
} else if (event.type === 'assistant/message') {
|
|
493
768
|
// Берём только видимый текст: рассуждения модели наружу не отдаём.
|
|
494
769
|
const blocks = event.data?.message?.content ?? [];
|
|
495
|
-
|
|
770
|
+
// Маркер цели наружу не отдаём — см. пояснение веткой выше.
|
|
771
|
+
const text = stripGoalMarker(blocks
|
|
496
772
|
.filter((b) => b?.type === 'text')
|
|
497
773
|
.map((b) => b.text)
|
|
498
|
-
.join('\n')
|
|
499
|
-
.trim();
|
|
774
|
+
.join('\n')).trim();
|
|
500
775
|
if (text) {
|
|
501
776
|
log(`[event] отправляю в Telegram chatId=${chatId} (${text.length} знаков)`);
|
|
502
777
|
{ const c = copyTo('tg'); if (c) sendCopy(c, sid, text); }
|
|
@@ -530,7 +805,6 @@ export function apply(ctx, config = {}) {
|
|
|
530
805
|
let tmpAudio = null;
|
|
531
806
|
try {
|
|
532
807
|
tmpAudio = await downloadTelegramFile(fileId);
|
|
533
|
-
// transcribe-local-shared — наша цепочка: sage-corrector → transcribe-whispercpp-core → whisper-warm.service
|
|
534
808
|
// 🔴 Внешняя команда расшифровки — НАСТРОЙКА, а не зашитый путь: у каждого
|
|
535
809
|
// своя цепочка. Не задана — голосовые просто не расшифровываются, и об этом
|
|
536
810
|
// говорится в журнал, а не молча игнорируется.
|
|
@@ -608,16 +882,41 @@ export function apply(ctx, config = {}) {
|
|
|
608
882
|
return;
|
|
609
883
|
}
|
|
610
884
|
|
|
885
|
+
// Постановка цели — тоже команда канала, разбираем до передачи агенту.
|
|
886
|
+
if (text === '/goal' || text.startsWith('/goal ')) {
|
|
887
|
+
// Право проверяем ДО создания агента: отказ не должен заводить сессию.
|
|
888
|
+
if (!goalUsers.has(Number(userId))) {
|
|
889
|
+
log(`[goal] ОТКАЗ ${userId}: не в списке goalUsers`);
|
|
890
|
+
await tg.send(chatId, 'Постановка цели из этого канала вам не разрешена.');
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const key = (MERGE_CHAT && String(chatId) === MERGE_CHAT) ? A2A_CHAT : chatId;
|
|
894
|
+
try {
|
|
895
|
+
const { handle: gh } = await agentFor(key);
|
|
896
|
+
await sayTo('tg', chatId, await goalCommand(text, 'tg', gh.agent));
|
|
897
|
+
} catch (e) {
|
|
898
|
+
log(`[goal] 🔴 команда не выполнена: ${e?.message ?? e}`);
|
|
899
|
+
await tg.send(chatId, `Не смог выполнить команду: ${e?.message ?? e}`);
|
|
900
|
+
}
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
611
904
|
// При включённом слиянии владелец попадает в ту же сессию, что и служебный
|
|
612
905
|
// канал: одна память на двоих. Ответ при этом обязан вернуться в мессенджер,
|
|
613
906
|
// поэтому запоминаем настоящий чат отдельно.
|
|
614
907
|
const routeKey = (MERGE_CHAT && String(chatId) === MERGE_CHAT) ? A2A_CHAT : chatId;
|
|
615
908
|
const { handle, sessionId } = await agentFor(routeKey);
|
|
616
909
|
tgChatFor.set(sessionId, chatId);
|
|
617
|
-
// 🔴
|
|
618
|
-
// followup объявлен в описании типов,
|
|
619
|
-
//
|
|
620
|
-
//
|
|
910
|
+
// 🔴 ЗДЕСЬ send(), А НЕ followup() — но причина СМЕНИЛАСЬ, читай внимательно.
|
|
911
|
+
// Было (0.1.0-rc.7): followup объявлен в описании типов, а В КОДЕ ЕГО НЕТ —
|
|
912
|
+
// вызов молча не делал ничего, снаружи «бот принял сообщение и замолчал».
|
|
913
|
+
// Стало (проверено на установленной 0.1.1-rc.2, 21.08.2026): followup в коде
|
|
914
|
+
// ЕСТЬ и он ровно наш вызов — dsh-agent-loop/lib/index.js:396
|
|
915
|
+
// followup(input) { this.send(input, "next-turn", true) }
|
|
916
|
+
// То есть обход больше не обход, а тот же самый путь, записанный явно.
|
|
917
|
+
// Оставляем send(): он работает на обеих версиях. Копии модуля разведены
|
|
918
|
+
// 22.08.2026 по агентам, но версии платформ у них разные (0.1.1-rc.2 и
|
|
919
|
+
// 0.1.0-rc.8), и код обязан работать на обеих.
|
|
621
920
|
// next-turn — обычный следующий ход;
|
|
622
921
|
// true — разбудить исполнителя, иначе сообщение будет ждать вечно.
|
|
623
922
|
lastOrigin.set(sessionId, 'tg');
|
|
@@ -654,8 +953,8 @@ export function apply(ctx, config = {}) {
|
|
|
654
953
|
// пользователя, а бот боту не пишет — так устроен мессенджер. Поэтому связь
|
|
655
954
|
// через каталог обмена, он же работает поверх ssh с любой машины фермы.
|
|
656
955
|
//
|
|
657
|
-
// входящие мне:
|
|
658
|
-
// исходящие ей:
|
|
956
|
+
// входящие мне: <дом агента>/a2a/in/*.txt — кладёт координатор, я передаю агенту
|
|
957
|
+
// исходящие ей: <дом агента>/a2a/out/*.txt — пишет агент, забирает координатор
|
|
659
958
|
// Каталог обмена задаётся настройкой: у каждого агента он свой. Не задан —
|
|
660
959
|
// межагентского канала у этого агента просто нет, и опрос не ведётся вовсе
|
|
661
960
|
// (молчаливое создание чужих каталогов было бы хуже отсутствия связи).
|
|
@@ -664,6 +963,54 @@ export function apply(ctx, config = {}) {
|
|
|
664
963
|
const A2A_OUT = A2A_DIR ? path.join(A2A_DIR, 'out') : null;
|
|
665
964
|
const A2A_CHAT = config.a2aSession || 'a2a'; // отдельная сессия, не смешивается с Telegram
|
|
666
965
|
|
|
966
|
+
// ── ВТОРОЙ ИСТОЧНИК ВХОДЯЩИХ: ЯЩИК ШИНЫ A2A (31.08.2026) ────────────────────
|
|
967
|
+
// Шина между агентами машины: отправитель кладёт письмо в щель
|
|
968
|
+
// /var/spool/a2a/<получатель>/ (каталог 1730: опустить можно, прочесть нельзя),
|
|
969
|
+
// почтальон a2a-pochtalon раз в 5 с переносит его в собственность получателя,
|
|
970
|
+
// а забрать из ящика и подать агенту должен ЭТОТ модуль.
|
|
971
|
+
//
|
|
972
|
+
// 🔴 ЗАЧЕМ ЗАВЕДЕНО: без этого блока выход шины не подключён никуда. Замер
|
|
973
|
+
// 31.08.2026: три письма соседа пролежали в ящике ДВОЕ СУТОК и дошли рукой
|
|
974
|
+
// координатора, а не шиной. Снаружи это выглядело как «агент не отвечает».
|
|
975
|
+
//
|
|
976
|
+
// Путь — НАСТРОЙКА, не константа: у каждого агента ящик свой, и требование
|
|
977
|
+
// владельца от 31.08.2026 — всё управляемое вынести из кода. Не задан — звена
|
|
978
|
+
// нет вовсе, опрос не ведётся (молчаливое чтение чужого ящика было бы хуже).
|
|
979
|
+
const SPOOL_IN = config.spoolDir || null;
|
|
980
|
+
// Порог пачки: до него письма подаются по одному (ход на письмо сохраняет
|
|
981
|
+
// структуру разговора), сверх него — ОДНИМ ходом, чтобы накопленное не
|
|
982
|
+
// превратилось в лавину подряд идущих ходов. Правило координатора 31.08.2026.
|
|
983
|
+
const SPOOL_BATCH = Number(config.spoolBatchCount ?? 5);
|
|
984
|
+
// Старше этого — подаётся с ЯВНОЙ пометкой возраста в тексте. Письмо
|
|
985
|
+
// двухсуточной давности, поданное как свежее, толкает отвечать на устаревшее.
|
|
986
|
+
const SPOOL_STALE_HOURS = Number(config.spoolStaleHours ?? 12);
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Отправитель — из ИМЕНИ файла: <метка>-<отправитель>-<случайное>.txt.
|
|
990
|
+
* Так решил почтальон: содержимое письма — сырой текст без заголовков, и
|
|
991
|
+
* подделать имя отправитель не может (имя формирует a2a-send, а каталог
|
|
992
|
+
* закрыт на перечисление). Не разобралось — говорим прямо, а не молчим.
|
|
993
|
+
*/
|
|
994
|
+
function spoolSender(name) {
|
|
995
|
+
const m = /^\d{8}T\d{6}Z-([^-]+)-/.exec(name);
|
|
996
|
+
return m ? m[1] : '(имя не разобрано)';
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Возраст письма в часах. Источник — метка в ИМЕНИ (её ставит отправитель),
|
|
1001
|
+
* а не mtime: почтальон копирует файл, и при потере метки времени mtime
|
|
1002
|
+
* оказался бы временем переноса, то есть свежим у двухсуточного письма.
|
|
1003
|
+
* Метка не разобралась — берём mtime как нижнюю оценку и говорим об этом.
|
|
1004
|
+
*/
|
|
1005
|
+
function spoolAgeHours(name, full) {
|
|
1006
|
+
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-/.exec(name);
|
|
1007
|
+
if (m) {
|
|
1008
|
+
const t = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
|
|
1009
|
+
return (Date.now() - t) / 3600000;
|
|
1010
|
+
}
|
|
1011
|
+
try { return (Date.now() - fs.statSync(full).mtimeMs) / 3600000; } catch { return 0; }
|
|
1012
|
+
}
|
|
1013
|
+
|
|
667
1014
|
// Берём простые текстовые форматы. Всё остальное НЕ берём — но и не
|
|
668
1015
|
// проглатываем: имя отвергнутого файла называем в журнале. Поймано
|
|
669
1016
|
// 21.08.2026: письмо с расширением .md пролежало во входящих 26 минут,
|
|
@@ -711,10 +1058,32 @@ export function apply(ctx, config = {}) {
|
|
|
711
1058
|
// который нам не по правам (например 600 root:root), молча оставался
|
|
712
1059
|
// лежать во входящих. Снаружи это «агент не отвечает», а на деле
|
|
713
1060
|
// мы даже не смогли прочитать вопрос. Поймано 18.08.2026.
|
|
714
|
-
log(`[a2a] 🔴 не смогла прочитать ${f}: ${e?.message ?? e} (права:
|
|
1061
|
+
log(`[a2a] 🔴 не смогла прочитать ${f}: ${e?.message ?? e} (права: файл должен принадлежать владельцу агента и быть читаемым, chmod 644)`);
|
|
715
1062
|
continue;
|
|
716
1063
|
}
|
|
717
1064
|
if (!text) continue;
|
|
1065
|
+
// Заголовок отправителя отделяем ВСЕГДА: он служебный и в разговоре лишний.
|
|
1066
|
+
const { sender, text: body } = splitSender(text);
|
|
1067
|
+
if (sender !== undefined) text = body;
|
|
1068
|
+
if (!text) continue;
|
|
1069
|
+
if (text === '/goal' || text.startsWith('/goal ')) {
|
|
1070
|
+
if (!goalA2ASenders.has(sender)) {
|
|
1071
|
+
log(`[goal] ОТКАЗ служебному каналу: отправитель ${sender ?? '(не назвался)'} не в списке goalA2ASenders`);
|
|
1072
|
+
await sayTo('a2a', null, sender === undefined
|
|
1073
|
+
? 'Постановка цели из служебного канала требует заголовка первой строкой: "From: <инстанс>/<папка>".'
|
|
1074
|
+
: `Отправитель ${sender} не вправе ставить цель.`);
|
|
1075
|
+
} else {
|
|
1076
|
+
try {
|
|
1077
|
+
const { handle: gh } = await agentFor(A2A_CHAT);
|
|
1078
|
+
await sayTo('a2a', null, await goalCommand(text, 'a2a', gh.agent));
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
log(`[goal] 🔴 команда не выполнена: ${e?.message ?? e}`);
|
|
1081
|
+
await sayTo('a2a', null, `Не смог выполнить команду: ${e?.message ?? e}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
try { fs.unlinkSync(full); } catch { /* уже убрали */ }
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
718
1087
|
try {
|
|
719
1088
|
const { handle, sessionId: a2aSessionId } = await agentFor(A2A_CHAT);
|
|
720
1089
|
lastOrigin.set(a2aSessionId, 'a2a');
|
|
@@ -726,10 +1095,28 @@ export function apply(ctx, config = {}) {
|
|
|
726
1095
|
// Наш агент на этом верно упёрся — отказался выполнять просьбу,
|
|
727
1096
|
// подписанную чужим именем, пришедшую не из своего канала. Значит
|
|
728
1097
|
// происхождение обязан сообщать код доставки, которому подделать нечем.
|
|
729
|
-
text = `[служебный канал, от
|
|
1098
|
+
text = `[служебный канал, от координатора${sender ? ` ${sender}` : ''}]\n${stripMark(text)}`;
|
|
730
1099
|
handle.agent.send(platform.createUserMessage({
|
|
731
1100
|
content: [{ type: 'text', text }],
|
|
732
|
-
|
|
1101
|
+
// 🔴 СВОЙ ВИД ИСТОЧНИКА ДЛЯ СЛУЖЕБНОГО КАНАЛА (21.08.2026, распоряжение
|
|
1102
|
+
// главной). Раньше тут стоял kind: 'user' — и это была НЕПРАВДА в слое
|
|
1103
|
+
// прав: платформа считала сообщение координатора человеческим, то есть
|
|
1104
|
+
// право ставить цель добывалось подлогом метки, а не записанным решением.
|
|
1105
|
+
// Теперь журнал честен: видно, что писал агент-координатор, а не человек.
|
|
1106
|
+
// ЧТО ЭТИМ ТЕРЯЕМ — обход ВСЕХ потребителей kind === 'user' в дереве
|
|
1107
|
+
// платформы (31 совпадение, разобраны все):
|
|
1108
|
+
// tool-goal hasDirectHumanInput -> false: из этого канала нельзя
|
|
1109
|
+
// закрыть цель родным инструментом (он у нас погашен);
|
|
1110
|
+
// tool-jobs бюджет подряд идущих пробуждений больше не обнуляется;
|
|
1111
|
+
// tool-skill жест /имя отсюда не подтянет тело скилла;
|
|
1112
|
+
// session-title заголовок сессии по такому сообщению не строится;
|
|
1113
|
+
// session-reference сообщение вне проекции «текущей поверхности».
|
|
1114
|
+
// Право ставить цель координатору вернём ЯВНЫМ списком видов в мосту —
|
|
1115
|
+
// решением, которое видно тому, кто откроет список.
|
|
1116
|
+
// Вид произвольный по устройству: createMessage кладёт source как есть,
|
|
1117
|
+
// схемы нет; 'goal' и 'skill-catalog' — такие же добавленные виды.
|
|
1118
|
+
// ⚠️ Личный чат владельца остаётся 'user' (строка 560) — он и есть человек.
|
|
1119
|
+
source: { kind: 'a2a' },
|
|
733
1120
|
}), 'next-turn', true);
|
|
734
1121
|
nudgeUntilClaimed(handle.agent, '[a2a]');
|
|
735
1122
|
// 🔴 УДАЛЯЕМ ТОЛЬКО ПОСЛЕ УСПЕШНОЙ ПЕРЕДАЧИ (19.08.2026). Раньше файл
|
|
@@ -743,10 +1130,115 @@ export function apply(ctx, config = {}) {
|
|
|
743
1130
|
}
|
|
744
1131
|
}
|
|
745
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* Забрать письма из ящика шины и подать агенту.
|
|
1135
|
+
*
|
|
1136
|
+
* 🔴 ДВА МЕСТА ОДНОЙ ДОСТАВКИ. Это короткий повтор того, что делает pollA2A;
|
|
1137
|
+
* общий кусок не выносила намеренно — в pollA2A он переплетён с разбором
|
|
1138
|
+
* /goal, и хелпер вышел бы дороже двух коротких кусков. Но перечень-копия
|
|
1139
|
+
* молча расходится: правишь ОДНО — проверь ВТОРОЕ. За этим следит
|
|
1140
|
+
* stend-yashchika-shiny.sh (сверяет, что оба места делают одно).
|
|
1141
|
+
*
|
|
1142
|
+
* Команд (/goal) из ящика НЕ принимаем: право ставить цель поимённое и живёт
|
|
1143
|
+
* у координатора прямым каналом. Второй путь к тому же действию всегда
|
|
1144
|
+
* расходится с первым, и по журналу перестаёт быть видно, кто поставил цель.
|
|
1145
|
+
*/
|
|
1146
|
+
async function pollSpool() {
|
|
1147
|
+
if (!SPOOL_IN) return;
|
|
1148
|
+
let all = [];
|
|
1149
|
+
try {
|
|
1150
|
+
all = fs.readdirSync(SPOOL_IN).sort();
|
|
1151
|
+
} catch (e) {
|
|
1152
|
+
// Одна строка на причину, не на прогон: опрос идёт каждые ~25 с.
|
|
1153
|
+
const key = `SPOOL:${e?.code ?? 'ERR'}`;
|
|
1154
|
+
if (!a2aReported.has(key)) {
|
|
1155
|
+
a2aReported.add(key);
|
|
1156
|
+
log(`[ящик] \u{1F534} недоступен (${SPOOL_IN}): ${e?.message ?? e}`);
|
|
1157
|
+
}
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
const files = all.filter((f) => A2A_EXT.some((x) => f.endsWith(x)));
|
|
1161
|
+
for (const f of all) {
|
|
1162
|
+
if (files.includes(f) || a2aReported.has(`SP:${f}`)) continue;
|
|
1163
|
+
a2aReported.add(`SP:${f}`);
|
|
1164
|
+
log(`[ящик] \u{1F534} НЕ ВЗЯТ файл ${f}: беру только ${A2A_EXT.join(', ')}. `
|
|
1165
|
+
+ `Он так и будет лежать в ящике — переименуй отправителю`);
|
|
1166
|
+
}
|
|
1167
|
+
for (const name of [...a2aReported]) {
|
|
1168
|
+
if (name.startsWith('SP:') && !all.includes(name.slice(3))) a2aReported.delete(name);
|
|
1169
|
+
}
|
|
1170
|
+
if (!files.length) return; // пустой ящик — тишина, а не строка «пусто»
|
|
1171
|
+
|
|
1172
|
+
// Читаем всё, что взяли, и только потом решаем: по одному или пачкой.
|
|
1173
|
+
const pisma = [];
|
|
1174
|
+
for (const f of files) {
|
|
1175
|
+
const full = path.join(SPOOL_IN, f);
|
|
1176
|
+
let text = '';
|
|
1177
|
+
try {
|
|
1178
|
+
text = fs.readFileSync(full, 'utf-8').trim();
|
|
1179
|
+
} catch (e) {
|
|
1180
|
+
log(`[ящик] \u{1F534} не смогла прочитать ${f}: ${e?.message ?? e} `
|
|
1181
|
+
+ `(письмо должно принадлежать владельцу агента; переносит почтальон)`);
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
if (!text) { try { fs.unlinkSync(full); } catch { /* пустое — убрать */ } continue; }
|
|
1185
|
+
const age = spoolAgeHours(f, full);
|
|
1186
|
+
const stale = age >= SPOOL_STALE_HOURS
|
|
1187
|
+
? `\n[письмо пролежало в ящике ${Math.round(age)} ч — возможно, оно устарело]`
|
|
1188
|
+
: '';
|
|
1189
|
+
pisma.push({ f, full, who: spoolSender(f), text: stripMark(text), stale });
|
|
1190
|
+
}
|
|
1191
|
+
if (!pisma.length) return;
|
|
1192
|
+
|
|
1193
|
+
const packoj = pisma.length > SPOOL_BATCH;
|
|
1194
|
+
const partii = packoj ? [pisma] : pisma.map((p) => [p]);
|
|
1195
|
+
for (const partiya of partii) {
|
|
1196
|
+
const who = partiya.length === 1 ? partiya[0].who : 'агентов машины';
|
|
1197
|
+
const body = partiya.length === 1
|
|
1198
|
+
? `[служебный канал, от агента ${partiya[0].who}]${partiya[0].stale}\n${partiya[0].text}`
|
|
1199
|
+
: `[служебный канал, ${partiya.length} писем из ящика одной пачкой]\n\n`
|
|
1200
|
+
+ partiya.map((p) => `── от агента ${p.who}${p.stale}\n${p.text}`).join('\n\n');
|
|
1201
|
+
try {
|
|
1202
|
+
const { handle, sessionId: a2aSessionId } = await agentFor(A2A_CHAT);
|
|
1203
|
+
lastOrigin.set(a2aSessionId, 'a2a');
|
|
1204
|
+
pushAsk(a2aSessionId, { origin: 'a2a', who, q: partiya[0].text });
|
|
1205
|
+
copyAsk('a2a', who, body);
|
|
1206
|
+
handle.agent.send(platform.createUserMessage({
|
|
1207
|
+
content: [{ type: 'text', text: body }],
|
|
1208
|
+
// Тот же вид источника, что у служебного канала: письмо агента — не
|
|
1209
|
+
// слово человека. Разбор последствий kind !== 'user' — в pollA2A выше.
|
|
1210
|
+
source: { kind: 'a2a' },
|
|
1211
|
+
}), 'next-turn', true);
|
|
1212
|
+
nudgeUntilClaimed(handle.agent, '[ящик]');
|
|
1213
|
+
// 🔴 УДАЛЯЕМ ТОЛЬКО ПОСЛЕ УСПЕШНОЙ ПЕРЕДАЧИ, но удаляем ОБЯЗАТЕЛЬНО:
|
|
1214
|
+
// модуль опрашивает ящик каждые ~25 с, и неудалённое письмо подавалось
|
|
1215
|
+
// бы снова и снова. Не удалить — не «сохранить улику», а завести лавину.
|
|
1216
|
+
for (const p of partiya) { try { fs.unlinkSync(p.full); } catch { /* уже убрали */ } }
|
|
1217
|
+
log(`[ящик] принято ${partiya.length} письмо(писем) от ${who}, ${body.length} знаков`);
|
|
1218
|
+
} catch (e) {
|
|
1219
|
+
log(`[ящик] \u{1F534} не удалось передать агенту: ${e?.message ?? e} — `
|
|
1220
|
+
+ `${partiya.map((p) => p.f).join(', ')} ОСТАВЛЕНЫ в ящике, попробую снова`);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// 🔴 fetch failed СКРЫВАЕТ причину (ECONNRESET/ETIMEDOUT/ENOTFOUND выглядят
|
|
1226
|
+
// одинаково). Печатаем e.cause рядом с e.message. ЦЕНА: не всякая ошибка несёт
|
|
1227
|
+
// cause — тогда пишем «не указана», а не пустое место (пустое читается как
|
|
1228
|
+
// «причины нет», и это та же слепота, что и раньше).
|
|
1229
|
+
// Перенесено из второй живой копии модуля 01.09.2026 ДОСЛОВНО: своя редакция
|
|
1230
|
+
// того же лечения развела бы копии по логике при сверке.
|
|
1231
|
+
function errDetail(e) {
|
|
1232
|
+
const c = e?.cause;
|
|
1233
|
+
if (!c) return 'не указана';
|
|
1234
|
+
return c?.message ?? c?.code ?? String(c);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
746
1237
|
async function loop(run) {
|
|
747
1238
|
while (run.alive) {
|
|
748
1239
|
try {
|
|
749
1240
|
await pollA2A();
|
|
1241
|
+
await pollSpool();
|
|
750
1242
|
const updates = await tg.poll(25);
|
|
751
1243
|
for (const u of updates) {
|
|
752
1244
|
try { await handleUpdate(u); }
|
|
@@ -754,7 +1246,7 @@ export function apply(ctx, config = {}) {
|
|
|
754
1246
|
}
|
|
755
1247
|
} catch (e) {
|
|
756
1248
|
// Сеть моргнула или Telegram ответил ошибкой — ждём и продолжаем.
|
|
757
|
-
log(`опрос не удался: ${e?.message ?? e}`);
|
|
1249
|
+
log(`опрос не удался: ${e?.message ?? e} | причина: ${errDetail(e)}`);
|
|
758
1250
|
await new Promise((r) => setTimeout(r, 3000));
|
|
759
1251
|
}
|
|
760
1252
|
}
|