dsh-telegram-multiagent 1.3.0 → 1.4.1
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 +157 -108
- package/cordis.patch.yml +9 -8
- package/package.json +9 -4
- package/src/client.js +0 -0
- package/src/index.js +379 -156
- package/test/extract.mjs +0 -0
- package/test/stend-imena.mjs +148 -0
- package/test/stend-pobudki.mjs +142 -0
- package/test/stend-yashchika-shiny.mjs +184 -0
- package/test/test-goal-control.mjs +16 -4
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
|
// перевыпустить, поставить заново — настройка останется. Читается на лету,
|
|
@@ -217,21 +221,57 @@ export function apply(ctx, config = {}) {
|
|
|
217
221
|
// 🔴 НИКАКИХ ТИХИХ ВЫХОДОВ: каждая ветка что-то пишет в журнал. Ровно на этом
|
|
218
222
|
// обжглись сегодня — `agent.inbox?.hasPending` при недоступном inbox даёт
|
|
219
223
|
// undefined, условие «всё хорошо» проходит, и отказ маскируется под успех.
|
|
224
|
+
// ── ЧЕТЫРЕ ИСХОДА ВМЕСТО ДВУХ (обосновано замером 01.09.2026).
|
|
225
|
+
// Прежняя редакция знала только «забрано» и «не забрали», и второе печаталось
|
|
226
|
+
// бедой. Замер за сутки: 48 писем принято, 45 строк «не забрали», побудок за всё
|
|
227
|
+
// время НОЛЬ, а настоящая задержка доставки — медиана 10 с. То есть беда
|
|
228
|
+
// объявлялась при штатной работе, 94 раза из 100.
|
|
229
|
+
//
|
|
230
|
+
// Две причины, обе исправлены здесь:
|
|
231
|
+
// 1) проверка «забрано ли» стояла ПОСЛЕ проверки занятости, и при занятом агенте
|
|
232
|
+
// очередь не смотрелась вовсе — выйти из цикла было структурно нельзя, пока
|
|
233
|
+
// агент работает. Теперь состояние очереди проверяется ПЕРВЫМ и от занятости
|
|
234
|
+
// не зависит: забрано — значит забрано, кто бы чем ни был занят;
|
|
235
|
+
// 2) «агент был занят весь срок» и «агент свободен, а письмо висит» — разные
|
|
236
|
+
// новости. Первое — сведение о штатном состоянии, второе — отказ механизма.
|
|
237
|
+
// Раньше обе давали одну красную строку, и настоящий отказ утонул бы в шуме.
|
|
238
|
+
//
|
|
239
|
+
// 🔴 ГДЕ НЕ ПРИМЕНЯЕТСЯ И ЧЕГО НЕ ЛОВИТ:
|
|
240
|
+
// • это наблюдение, а не доставка: письмо доходит и без побудки, платформа
|
|
241
|
+
// заберёт его на следующем ходе. Молчание механизма не означает потери;
|
|
242
|
+
// • «занят все проверки» не доказывает, что письмо доставлено — только что
|
|
243
|
+
// будить было некого. Момент забора виден лишь при выходе по пустой очереди;
|
|
244
|
+
// • очередь, опустевшая уже на первой проверке, неотличима от «письмо вклеено
|
|
245
|
+
// в идущий ход» — поэтому такой случай помечается отдельно, а не молчит.
|
|
246
|
+
//
|
|
247
|
+
// 🔴 НИКАКИХ ТИХИХ ВЫХОДОВ: каждая ветка что-то пишет в журнал.
|
|
220
248
|
function nudgeUntilClaimed(agent, tag, budgetMs = 120000) {
|
|
221
249
|
void (async () => {
|
|
222
|
-
const
|
|
223
|
-
|
|
250
|
+
const nachato = Date.now();
|
|
251
|
+
const deadline = nachato + budgetMs;
|
|
252
|
+
let woke = 0; // сколько раз будили
|
|
253
|
+
let proverok = 0; // сколько проходов сделали
|
|
254
|
+
let zanyat = 0; // из них пришлось на занятого агента
|
|
255
|
+
let videliOchered = false; // видели ли очередь непустой хоть раз
|
|
224
256
|
while (Date.now() < deadline) {
|
|
225
257
|
await new Promise((r) => setTimeout(r, 500));
|
|
258
|
+
proverok += 1;
|
|
226
259
|
if (typeof agent?.inbox?.hasPending !== 'boolean') {
|
|
227
260
|
log(`${tag} 🔴 очередь агента недоступна — добивать побудку нечем`);
|
|
228
261
|
return;
|
|
229
262
|
}
|
|
230
|
-
|
|
263
|
+
// Забрано ли — вопрос о ПИСЬМЕ, а не об агенте. Спрашиваем всегда.
|
|
231
264
|
if (!agent.inbox.hasPending) {
|
|
232
|
-
|
|
265
|
+
const sek = ((Date.now() - nachato) / 1000).toFixed(1);
|
|
266
|
+
const primech = videliOchered
|
|
267
|
+
? ''
|
|
268
|
+
: ' (очередь была пуста уже на первой проверке — вероятно, письмо вклеено в идущий ход)';
|
|
269
|
+
log(`${tag} сообщение забрано за ${sek} с: побудок ${woke}, проверок ${proverok}${primech}`);
|
|
233
270
|
return;
|
|
234
271
|
}
|
|
272
|
+
videliOchered = true;
|
|
273
|
+
// А вот будить — вопрос об АГЕНТЕ: занятого будить незачем и нечем.
|
|
274
|
+
if (agent.status === 'running') { zanyat += 1; continue; }
|
|
235
275
|
if (typeof agent.wakeDriver !== 'function') {
|
|
236
276
|
log(`${tag} 🔴 сообщение висит в очереди, а wakeDriver недоступен`);
|
|
237
277
|
return;
|
|
@@ -239,7 +279,11 @@ export function apply(ctx, config = {}) {
|
|
|
239
279
|
agent.wakeDriver();
|
|
240
280
|
woke += 1;
|
|
241
281
|
}
|
|
242
|
-
|
|
282
|
+
if (proverok > 0 && zanyat === proverok) {
|
|
283
|
+
log(`${tag} письмо ещё в очереди спустя ${budgetMs} мс: агент был занят все ${zanyat} проверок из ${proverok} — будить было некого, состояние штатное`);
|
|
284
|
+
} else {
|
|
285
|
+
log(`${tag} 🔴 за ${budgetMs} мс сообщение так и не забрали: агент был свободен ${proverok - zanyat} проверок из ${proverok}, побудок ${woke} — побудка не помогла`);
|
|
286
|
+
}
|
|
243
287
|
})();
|
|
244
288
|
}
|
|
245
289
|
|
|
@@ -271,24 +315,19 @@ export function apply(ctx, config = {}) {
|
|
|
271
315
|
* cordis возвращает undefined БЕЗ ошибки, пока волокно поставщика не активно
|
|
272
316
|
* (ReflectService._getImpl: `if (strict && impl.fiber.state !== 2) return;`).
|
|
273
317
|
* Значит «сервиса нет в сборке» и «сервис ещё поднимается» приходят одним и
|
|
274
|
-
* тем же значением, и
|
|
275
|
-
*
|
|
276
|
-
* штатной работой: агент собирается, отвечает и молчит о том, чего лишился.
|
|
318
|
+
* тем же значением, и код вида `const x = ctx.get('x'); if (x)` тихо уходит по
|
|
319
|
+
* ветке «этой возможности просто нет». Снаружи это штатная работа.
|
|
277
320
|
*
|
|
278
|
-
* Поймано трижды за три
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
* без ошибки и без строки в журнале.
|
|
285
|
-
* Общий вывод, ради которого функция ОДНА, а не три: лечение, применённое к
|
|
286
|
-
* экземпляру, а не ко всем местам класса, гарантирует рецидив.
|
|
321
|
+
* Поймано трижды за три дня: 19.08 фабрика агентов (withFactoryRetry выше),
|
|
322
|
+
* 22.08 sessionPersistence (блок продолжения пропускался целиком, агент
|
|
323
|
+
* создавался поверх существующего журнала, каждый ход умирал с «id collision»),
|
|
324
|
+
* 22.08 agentPresets (набор инструментов у соседа упал с 33 до 3 без единой
|
|
325
|
+
* строки в журнале). Лечение экземпляра вместо класса гарантирует рецидив —
|
|
326
|
+
* поэтому функция ОДНА и форма у неё одна с опубликованным пакетом 1.2.3.
|
|
287
327
|
*
|
|
288
|
-
* ГРАНИЦА
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
* где сервиса в сборке нет вовсе (это законная сборка, а не поломка).
|
|
328
|
+
* ГРАНИЦА ОЖИДАНИЯ: не дольше `tries` секунд и только при заведении агента.
|
|
329
|
+
* Не дождались — говорим ГРОМКО, называем последствие и идём дальше. Канал не
|
|
330
|
+
* запирается там, где сервиса в сборке нет вовсе: это законная сборка.
|
|
292
331
|
*/
|
|
293
332
|
async function waitService(name, tag, consequence, tries = 30) {
|
|
294
333
|
for (let i = 0; i < tries; i++) {
|
|
@@ -305,36 +344,38 @@ export function apply(ctx, config = {}) {
|
|
|
305
344
|
}
|
|
306
345
|
|
|
307
346
|
/**
|
|
308
|
-
* 🔴 РЕШЕНИЕ ПО КАЖДОМУ ctx.get() В ЭТОМ
|
|
309
|
-
*
|
|
310
|
-
*
|
|
347
|
+
* 🔴 РЕШЕНИЕ ПО КАЖДОМУ ctx.get() В ЭТОМ ФАЙЛЕ (23.08.2026, уточнено при
|
|
348
|
+
* сведении с копией соседа). Проверять надо КАЖДЫЙ, а не тот, на котором
|
|
349
|
+
* обожглись, — иначе рецидив.
|
|
311
350
|
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
351
|
+
* ПРИЗНАК: ожидание нужно там, где место может сработать ДО подъёма сервиса
|
|
352
|
+
* И отказ при этом МОЛЧАЛИВ ИЛИ ЛОЖЕН. Второе добавлено сознательно: раньше
|
|
353
|
+
* здесь было записано «отказ громкий — значит ждать не надо», и это было
|
|
354
|
+
* ошибкой. Громкий отказ «сервис не подключён» — это ДИАГНОЗ, которого код
|
|
355
|
+
* поставить не может: undefined одинаково означает и «нет в сборке», и «ещё
|
|
356
|
+
* поднимается». Механизм не вправе утверждать то, чего не в силах установить.
|
|
315
357
|
*
|
|
316
|
-
*
|
|
317
|
-
* к
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* места, а не по тому, где однажды видели примету.
|
|
358
|
+
* И отдельно, доказано на живом 23.08: порядок готовности волокон МЕНЯЕТСЯ
|
|
359
|
+
* от подъёма к подъёму — в одном ждала фабрика агентов, в другом набор
|
|
360
|
+
* пресета, а гонка была в обоих. Значит судить о гонке по соседнему сервису
|
|
361
|
+
* нельзя, ожидание ставится по устройству места, а не по примете.
|
|
321
362
|
*
|
|
322
|
-
* 1) agentFor → agentPresets, agentDefaultModel, sessionPersistence
|
|
323
|
-
* ОЖИДАНИЕ ЕСТЬ, предел 30
|
|
324
|
-
* платформы,
|
|
325
|
-
* 2) goalCommand → goals, goalMarker, mcpBridge
|
|
326
|
-
* короткий (GOAL_WAIT_TRIES)
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* конце живой собеседник.
|
|
330
|
-
* 3) stripGoalMarker → goalMarker. ОЖИДАНИЯ НЕТ намеренно: место
|
|
331
|
-
* СИНХРОННОЕ (обработчик события сессии не async), а отказ не молчалив —
|
|
363
|
+
* 1) agentFor → agentPresets, agentDefaultModel, sessionPersistence:
|
|
364
|
+
* ОЖИДАНИЕ ЕСТЬ, предел 30 с — заведение агента идёт вплотную к подъёму
|
|
365
|
+
* платформы, отказ молчалив и разрушителен.
|
|
366
|
+
* 2) goalCommand → goals, goalMarker, mcpBridge: ОЖИДАНИЕ ЕСТЬ, предел
|
|
367
|
+
* короткий (GOAL_WAIT_TRIES) — на том конце живой собеседник.
|
|
368
|
+
* 3) stripGoalMarker → goalMarker: ОЖИДАНИЯ НЕТ намеренно. Место
|
|
369
|
+
* СИНХРОННОЕ (обработчик события сессии не async), а отказ не молчалив:
|
|
332
370
|
* маркер уезжает в чат видимым текстом.
|
|
333
|
-
* 4) обработчик goal/change → goals
|
|
334
|
-
* только
|
|
335
|
-
* сервиса целей не
|
|
336
|
-
*
|
|
371
|
+
* 4) обработчик goal/change → goals: ОЖИДАНИЯ НЕТ намеренно. Сюда попадают
|
|
372
|
+
* только сессии, где цель уже поставлена через goalCommand, а тот без
|
|
373
|
+
* сервиса целей не проходит. Значит пустота означает не «ещё не
|
|
374
|
+
* поднялся», а «сервис ПРОПАЛ», и это надо сказать, а не переждать.
|
|
337
375
|
*/
|
|
376
|
+
// Сколько секунд ждать сервис на пути КОМАНДЫ (не на заведении агента).
|
|
377
|
+
// Пять, а не тридцать: на том конце живой собеседник, см. goalCommand.
|
|
378
|
+
const GOAL_WAIT_TRIES = 5;
|
|
338
379
|
|
|
339
380
|
async function agentFor(chatId) {
|
|
340
381
|
const key = String(chatId);
|
|
@@ -359,8 +400,6 @@ export function apply(ctx, config = {}) {
|
|
|
359
400
|
const setupFn = async (agentCtx) => {
|
|
360
401
|
platform.installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
361
402
|
if (presets && presetId) await presets.mount(agentCtx, presetId);
|
|
362
|
-
// Молча остаться без инструментов нельзя: снаружи это выглядит как
|
|
363
|
-
// «агент отупел», а причина — несмонтированный пресет.
|
|
364
403
|
else log(`[${key}] 🔴 пресет НЕ смонтирован (presets=${!!presets}, presetId=${presetId})`);
|
|
365
404
|
};
|
|
366
405
|
|
|
@@ -376,6 +415,17 @@ export function apply(ctx, config = {}) {
|
|
|
376
415
|
// события turn/end (раньше плагин печатал только тип события).
|
|
377
416
|
// Правильный путь — ctx.agents.resume(): «загрузить сохранённую сессию и
|
|
378
417
|
// продолжить агента на ней» (dsh-agent/lib/index.js, resume()).
|
|
418
|
+
// 🔴 СЕРВИС ПЕРСИСТЕНЦИИ ПОЯВЛЯЕТСЯ ПОЗЖЕ КАНАЛА — та же болезнь, что у фабрики
|
|
419
|
+
// агентов выше, и тот же почерк: ctx.get() отдаёт undefined ТИХО, пока волокно
|
|
420
|
+
// поставщика не активно (cordis, ReflectService._getImpl:
|
|
421
|
+
// `if (strict && impl.fiber.state !== 2) return;`),
|
|
422
|
+
// исключения при этом нет. Без ожидания весь блок продолжения молча
|
|
423
|
+
// пропускался, агент создавался заново поверх существующего журнала, и КАЖДЫЙ
|
|
424
|
+
// ход умирал с «id collision». Поймано 22.08.2026: подъём 18:53 не напечатал
|
|
425
|
+
// ни строки о продолжении, ни об отказе — молчание и было ответом.
|
|
426
|
+
// ГРАНИЦА ОЖИДАНИЯ: ждём не дольше 30 с и только здесь, при заведении агента.
|
|
427
|
+
// Не дождались — говорим об этом громко и идём создавать, иначе канал запрётся
|
|
428
|
+
// навсегда там, где персистенции в сборке просто нет.
|
|
379
429
|
const persistence = await waitService('sessionPersistence', `[${key}]`,
|
|
380
430
|
'сохранённую сессию продолжить нечем, история будет потеряна');
|
|
381
431
|
let handle;
|
|
@@ -384,15 +434,16 @@ export function apply(ctx, config = {}) {
|
|
|
384
434
|
try {
|
|
385
435
|
known = (await persistence.list()).map((h) => h.id);
|
|
386
436
|
} catch (e) {
|
|
387
|
-
log(`🔴 [${key}] не
|
|
437
|
+
log(`🔴 [${key}] не смогла перечислить сохранённые сессии: ${e?.message ?? e}`);
|
|
388
438
|
}
|
|
389
|
-
// 🔴 УХОДИТЬ В create() МОЛЧА
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
439
|
+
// 🔴 УХОДИТЬ В create() МОЛЧА НЕЛЬЗЯ. Снаружи это неотличимо от штатного
|
|
440
|
+
// первого запуска, а на деле означает потерю истории и id collision на
|
|
441
|
+
// каждом ходе. Поэтому отсутствие своей сессии в списке — громкая строка
|
|
442
|
+
// с числом найденных и их именами, а не тишина.
|
|
393
443
|
if (known !== undefined && !known.includes(sessionId)) {
|
|
394
|
-
// Пустой список — честный первый запуск, тревожить нечем.
|
|
395
|
-
// где сохранённые сессии ЕСТЬ, а нашей среди них
|
|
444
|
+
// Пустой список — это честный первый запуск, а не беда: тревожить нечем.
|
|
445
|
+
// Тревога начинается там, где сохранённые сессии ЕСТЬ, а нашей среди них
|
|
446
|
+
// нет: значит журнал на диске остался, и create() сейчас об него разобьётся.
|
|
396
447
|
if (known.length === 0) log(`[${key}] сохранённых сессий на диске нет — ${sessionId} заводится впервые`);
|
|
397
448
|
else {
|
|
398
449
|
// ⚠️, а не 🔴, СОЗНАТЕЛЬНО: по одному списку два исхода неразличимы —
|
|
@@ -476,36 +527,33 @@ export function apply(ctx, config = {}) {
|
|
|
476
527
|
}
|
|
477
528
|
}
|
|
478
529
|
|
|
479
|
-
// ── ПОСТАНОВКА ЦЕЛИ ИЗ КАНАЛА (21.08.2026,
|
|
530
|
+
// ── ПОСТАНОВКА ЦЕЛИ ИЗ КАНАЛА (21.08.2026, распоряжение главной).
|
|
480
531
|
//
|
|
481
|
-
// ЗАЧЕМ.
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
532
|
+
// ЗАЧЕМ. На подписочном маршруте цикл ведёт движок, поэтому родные
|
|
533
|
+
// инструменты платформы (goal/jobs) до модели НЕ ДОХОДЯТ вовсе — доходит
|
|
534
|
+
// только то, что имеет форму СООБЩЕНИЯ. Значит поставить цель изнутри
|
|
535
|
+
// разговора нельзя, а руками в веб-интерфейсе — можно. Здесь мы даём
|
|
485
536
|
// постановку из любого канала: сервис целей доступен КОДУ модуля как
|
|
486
537
|
// ctx.get('goals'), инструмент модели для этого не нужен.
|
|
487
538
|
//
|
|
488
539
|
// КТО ВПРАВЕ. Отдельные настройки, обе по умолчанию ВЫКЛЮЧЕНЫ:
|
|
489
|
-
// goalUsers: [<числовые id>]
|
|
490
|
-
//
|
|
540
|
+
// goalUsers: [<числовые id>] — кто ставит цель из Telegram
|
|
541
|
+
// goalFromA2A: true|false — вправе ли служебный канал
|
|
491
542
|
// Право НЕ наследуется от allowedUsers сознательно: allowedUsers — это
|
|
492
543
|
// «с кем я вообще разговариваю», и завтра туда добавят гостя. Право завести
|
|
493
544
|
// дорогой автономный цикл шире права поговорить.
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
// и на ЭТОМ пути не участвуют. Мы не обходим политику — мы пишем её заново,
|
|
498
|
-
// и две строки ниже есть вся политика целиком. Пустые списки = постановка
|
|
499
|
-
// отключена; это умолчание выбрано нарочно.
|
|
545
|
+
// Проверки платформы (currentInitiator, hasDirectHumanInput, roots) живут в
|
|
546
|
+
// tool-goal и на этом пути НЕ участвуют: мы не обходим политику, мы пишем её
|
|
547
|
+
// заново, и вот эти две строки — вся политика целиком.
|
|
500
548
|
const goalUsers = new Set((config.goalUsers ?? []).map(Number));
|
|
501
|
-
// 🔴 ПРАВО ИЗ СЛУЖЕБНОГО КАНАЛА — ПОИМЁННО, А НЕ «КАНАЛУ
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
// раздали бы возможность заводить дорогие циклы кому попало.
|
|
505
|
-
// Отправитель называет себя первой строкой файла: From:
|
|
549
|
+
// 🔴 ПРАВО ИЗ СЛУЖЕБНОГО КАНАЛА — ПОИМЁННО, А НЕ «КАНАЛУ ЦЕЛИКОМ» (21.08.2026,
|
|
550
|
+
// поправка главной к собственному замыслу). Вид источника 'a2a' получает ЛЮБОЕ
|
|
551
|
+
// сообщение служебного канала, а канал на ферме общий — правом «всем, кто пишет
|
|
552
|
+
// в a2a» мы раздали бы возможность заводить дорогие циклы кому попало.
|
|
553
|
+
// Отправитель называет себя первой строкой файла: From: <инстанс>/<папка>
|
|
506
554
|
// Нет заголовка или имени нет в списке — отказ, и он произносится вслух.
|
|
507
555
|
const goalA2ASenders = new Set(config.goalA2ASenders ?? []);
|
|
508
|
-
// sessionId → { channel,
|
|
556
|
+
// sessionId → { channel, chatId } — куда сообщить об исходе цели.
|
|
509
557
|
const goalOrigin = new Map();
|
|
510
558
|
// канал → метки времени постановок, для счётчика ниже.
|
|
511
559
|
const goalStarts = new Map();
|
|
@@ -513,26 +561,22 @@ export function apply(ctx, config = {}) {
|
|
|
513
561
|
// 🔴 СЧЁТЧИК ПОСТАНОВОК: не более трёх за скользящий час на канал.
|
|
514
562
|
//
|
|
515
563
|
// ОТ ЧЕГО ЗАЩИЩАЕТ: от СЛУЧАЙНОСТИ и РАЗГОНА — от петли, в которую механизм
|
|
516
|
-
// вошёл сам, от ошибки в коде, от лавины повторов. Предел раундов
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
564
|
+
// вошёл сам, от ошибки в коде, от лавины повторов. Предел раундов стоит на
|
|
565
|
+
// ЦЕЛЬ, а не на агента: новая цель — новый счётчик, и поток целей ничем не
|
|
566
|
+
// ограничен. Каталог входящих принадлежит самому агенту, то есть в автономном
|
|
567
|
+
// раунде он способен положить себе файл «/goal …» и продлить цикл.
|
|
520
568
|
//
|
|
521
|
-
// 🔴 ГДЕ НЕ ПРИМЕНЯЕТСЯ:
|
|
522
|
-
// у которого
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
569
|
+
// 🔴 ГДЕ НЕ ПРИМЕНЯЕТСЯ: он НЕ защищает от намеренного действия агента,
|
|
570
|
+
// у которого на этой машине полное sudo без пароля (проверено 21.08.2026:
|
|
571
|
+
// <агент> ALL NOPASSWD: ALL). Такой агент снимет и этот счётчик, и предел
|
|
572
|
+
// раундов одной командой. Технической границы против самого агента здесь
|
|
573
|
+
// не существует, изоляция ПОВЕДЕНЧЕСКАЯ. Настоящий сторож — снаружи, на
|
|
574
|
+
// другой машине, вне досягаемости этого процесса.
|
|
526
575
|
//
|
|
527
576
|
// ГРАНИЦЫ САМОГО СЧЁТЧИКА: снятию цели (/goal stop) он не мешает никогда —
|
|
528
577
|
// механизм, включаемый снаружи и невыключаемый, хуже отсутствия механизма;
|
|
529
578
|
// обнуляется перезапуском процесса; ничего не чинит сам, только отказывает.
|
|
530
579
|
const GOAL_MAX_PER_HOUR = 3;
|
|
531
|
-
|
|
532
|
-
// Сколько секунд ждать сервис на пути КОМАНДЫ (не на заведении агента).
|
|
533
|
-
// Пять, а не тридцать: см. пояснение в goalCommand ниже.
|
|
534
|
-
const GOAL_WAIT_TRIES = 5;
|
|
535
|
-
|
|
536
580
|
function goalRateOk(channel) {
|
|
537
581
|
const now = Date.now();
|
|
538
582
|
const marks = (goalStarts.get(channel) ?? []).filter((t) => now - t < 3600000);
|
|
@@ -540,12 +584,12 @@ export function apply(ctx, config = {}) {
|
|
|
540
584
|
return marks.length < GOAL_MAX_PER_HOUR;
|
|
541
585
|
}
|
|
542
586
|
|
|
543
|
-
// 🔴 ПРИЗНАК СОБСТВЕННОЙ
|
|
544
|
-
//
|
|
545
|
-
// инструмента и в заголовке невидим. Печатаем
|
|
546
|
-
// списка прав отличает верную настройку от
|
|
547
|
-
// постановка отказывает молча и выглядит поломкой.
|
|
548
|
-
// печатаем — в журнал уходит устройство, а не список людей.
|
|
587
|
+
// 🔴 ПРИЗНАК СОБСТВЕННОЙ ИСПРАВНОСТИ ПЛАГИНА (22.08.2026, распоряжение главной).
|
|
588
|
+
// Права на постановку цели заголовком request/header НЕ проверяются в принципе:
|
|
589
|
+
// плагин не даёт модели ни одного инструмента и в заголовке невидим. Печатаем
|
|
590
|
+
// ЧИСЛА, а не «загрузился»: длина списка прав отличает верную настройку от
|
|
591
|
+
// пустого умолчания, при котором постановка отказывает молча и выглядит поломкой.
|
|
592
|
+
// Сами идентификаторы не печатаем — в журнал уходит устройство, а не список людей.
|
|
549
593
|
log(`[goal] смонтирован: goalUsers=${goalUsers.size} goalA2ASenders=${goalA2ASenders.size} `
|
|
550
594
|
+ `предел постановок=${GOAL_MAX_PER_HOUR}/час`);
|
|
551
595
|
|
|
@@ -566,11 +610,25 @@ export function apply(ctx, config = {}) {
|
|
|
566
610
|
*
|
|
567
611
|
* 🔴 ЧЕСТНАЯ ГРАНИЦА, назвать её обязательно: это УЧЁТ, А НЕ ЗАЩИТА.
|
|
568
612
|
* Отправитель называет себя сам, и подделать имя может любой, кто способен
|
|
569
|
-
* положить файл в каталог входящих. Настоящая граница здесь — права
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
613
|
+
* положить файл в каталог входящих. Настоящая граница здесь — права ОС:
|
|
614
|
+
* дом агента открыт на проход только владельцу (drwxr-x---), поэтому писать
|
|
615
|
+
* во входящие могут лишь сам агент и тот, у кого есть sudo. Проверено
|
|
616
|
+
* опытом 21.08.2026: dsh и arg получают Permission denied и на чтение, и на
|
|
617
|
+
* запись. Владелец файла отправителя НЕ выдаёт вовсе — доставка кладёт файл
|
|
618
|
+
* от root и тут же делает chown на адресата.
|
|
619
|
+
* Нужна проверка сильнее самоназвания — это общий секрет в файле с правами,
|
|
620
|
+
* и заводить его нужно решением, а не походя.
|
|
573
621
|
*/
|
|
622
|
+
/**
|
|
623
|
+
* Убрать маркер цели из текста, уходящего наружу. Если плагин разбора не
|
|
624
|
+
* смонтирован — текст идёт как есть: маркер в чате некрасив, но безвреден,
|
|
625
|
+
* а молча глотать ответ из-за отсутствия служебного плагина нельзя.
|
|
626
|
+
*/
|
|
627
|
+
function stripGoalMarker(raw) {
|
|
628
|
+
const marker = ctx.get('goalMarker');
|
|
629
|
+
return marker ? marker.strip(raw) : raw;
|
|
630
|
+
}
|
|
631
|
+
|
|
574
632
|
function splitSender(raw) {
|
|
575
633
|
const nl = raw.indexOf('\n');
|
|
576
634
|
const head = (nl === -1 ? raw : raw.slice(0, nl)).trim();
|
|
@@ -579,31 +637,18 @@ export function apply(ctx, config = {}) {
|
|
|
579
637
|
return { sender: m[1], text: nl === -1 ? '' : raw.slice(nl + 1).trimStart() };
|
|
580
638
|
}
|
|
581
639
|
|
|
582
|
-
/**
|
|
583
|
-
* Убрать маркер цели из текста, уходящего наружу.
|
|
584
|
-
*
|
|
585
|
-
* 🔴 ЗДЕСЬ ОЖИДАНИЯ СЕРВИСА НЕТ, И ЭТО РЕШЕНИЕ, А НЕ ПРОПУСК. Место
|
|
586
|
-
* СИНХРОННОЕ (зовётся из обработчика session/event, который не async), и
|
|
587
|
-
* отказ здесь НЕ молчалив: маркер уезжает в чат видимым текстом, читатель
|
|
588
|
-
* увидит его сам. Ждать по 30 с на каждом исходящем сообщении было бы
|
|
589
|
-
* лечением хуже болезни — см. общее решение по ctx.get() выше.
|
|
590
|
-
*/
|
|
591
|
-
function stripGoalMarker(raw) {
|
|
592
|
-
const marker = ctx.get('goalMarker');
|
|
593
|
-
return marker ? marker.strip(raw) : raw;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
640
|
// Разбор команды. Возвращает текст ответа; отправку делает вызывающий.
|
|
597
641
|
// Отказы называем вслух: молчаливый отказ здесь неотличим от «агент не понял».
|
|
598
642
|
async function goalCommand(text, channel, agent) {
|
|
599
643
|
const rest = text.slice('/goal'.length).trim();
|
|
600
|
-
// 🔴 ЖДЁМ СЕРВИС, А НЕ ОТКАЗЫВАЕМ СРАЗУ
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
const goals = await waitService('goals', '[goal]',
|
|
644
|
+
// 🔴 ЖДЁМ СЕРВИС, А НЕ ОТКАЗЫВАЕМ СРАЗУ (23.08.2026, сведение с копией
|
|
645
|
+
// соседа — решение его, форма наша). Без ожидания ответ «не подключён» был
|
|
646
|
+
// бы диагнозом, которого код поставить не может: undefined одинаково
|
|
647
|
+
// означает и «нет в сборке», и «ещё поднимается». Предел короткий, а не
|
|
648
|
+
// 30 с как при заведении агента: на том конце ждёт живой собеседник, и
|
|
649
|
+
// полминуты молчания в чате читаются как «бот умер».
|
|
650
|
+
const goals = await waitService('goals', '[goal]',
|
|
651
|
+
'команда постановки цели невыполнима', GOAL_WAIT_TRIES);
|
|
607
652
|
if (!goals) return 'Сервис целей на этом агенте не подключён.';
|
|
608
653
|
let current;
|
|
609
654
|
try { current = goals.get(agent); }
|
|
@@ -631,8 +676,8 @@ export function apply(ctx, config = {}) {
|
|
|
631
676
|
}
|
|
632
677
|
|
|
633
678
|
// Постановка. Предел раундов командой НЕ задаётся: он живёт в настройке
|
|
634
|
-
//
|
|
635
|
-
// дорогой цикл.
|
|
679
|
+
// (defaultMaxGoalRounds) и не должен подниматься тем же путём, каким
|
|
680
|
+
// запускается дорогой цикл.
|
|
636
681
|
if (!goalRateOk(channel)) {
|
|
637
682
|
log(`[goal] ОТКАЗ ${channel}: исчерпан предел ${GOAL_MAX_PER_HOUR} постановок за час`);
|
|
638
683
|
return `Предел ${GOAL_MAX_PER_HOUR} постановок цели за час исчерпан. Зовите человека.`;
|
|
@@ -640,19 +685,19 @@ export function apply(ctx, config = {}) {
|
|
|
640
685
|
try {
|
|
641
686
|
let view = goals.create(agent, { objective: rest });
|
|
642
687
|
// 🔴 НАСТАВЛЕНИЕ ПРО МАРКЕР КЛАДЁМ В САМ ЗАМЫСЕЛ. Промпт автоматического
|
|
643
|
-
// раунда несёт только
|
|
644
|
-
// ревизии в нём нет, а без них модель не сложит
|
|
645
|
-
// известен только после create, поэтому edit вторым
|
|
646
|
-
// ревизию на единицу, её и называем в наставлении.
|
|
688
|
+
// раунда (renderGoalRoundPrompt) несёт только Objective и Round — ни
|
|
689
|
+
// идентификатора цели, ни ревизии в нём нет, а без них модель не сложит
|
|
690
|
+
// маркер. Идентификатор известен только после create, поэтому edit вторым
|
|
691
|
+
// шагом; edit поднимает ревизию на единицу, её и называем в наставлении.
|
|
647
692
|
const marker = await waitService('goalMarker', '[goal]',
|
|
648
693
|
'цель будет поставлена без наставления о маркере', GOAL_WAIT_TRIES);
|
|
649
694
|
if (marker) {
|
|
650
|
-
// 🔴 ИМЕНА ИНСТРУМЕНТОВ БЕРЁМ У МОСТА, А НЕ ПИШЕМ ПО
|
|
651
|
-
// MCP добавляет к базовому имени приставку mcp__<сервер>__;
|
|
652
|
-
// называющее инструмент БЕЗ неё, отсылает модель к
|
|
653
|
-
// имени, и выглядит это как «мост не работает».
|
|
654
|
-
// тогда наставление честно говорит, что
|
|
655
|
-
// только пометка.
|
|
695
|
+
// 🔴 ИМЕНА ИНСТРУМЕНТОВ БЕРЁМ У МОСТА, А НЕ ПИШЕМ ПО ПАМЯТИ (22.08.2026).
|
|
696
|
+
// Протокол MCP добавляет к базовому имени приставку mcp__<сервер>__;
|
|
697
|
+
// наставление, называющее инструмент БЕЗ неё, отсылает модель к
|
|
698
|
+
// несуществующему имени, и выглядит это как «мост не работает».
|
|
699
|
+
// Нет моста — нет и имён: тогда наставление честно говорит, что
|
|
700
|
+
// инструментов нет, и остаётся только пометка.
|
|
656
701
|
const bridge = await waitService('mcpBridge', '[goal]',
|
|
657
702
|
'в наставлении останется только пометка, без имён инструментов', GOAL_WAIT_TRIES);
|
|
658
703
|
const goalTools = bridge?.modelToolName
|
|
@@ -681,19 +726,20 @@ export function apply(ctx, config = {}) {
|
|
|
681
726
|
// 🔴 DEBUG 2026-08-18: все типы событий
|
|
682
727
|
log(`[event] type=${event.type} session.id=${session?.id} knownSessions=${[...sessionToChat.keys()].join('|')}`);
|
|
683
728
|
const sid = String(session?.id ?? '');
|
|
684
|
-
// 🔴 ИСХОД ЦЕЛИ СООБЩАЕМ САМИ. Доводчик при упоре в предел
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
// Блок стоит ДО проверки chatId нарочно: у служебного канала числового чата
|
|
689
|
-
// нет вовсе, и ранний выход съел бы событие.
|
|
729
|
+
// 🔴 ИСХОД ЦЕЛИ СООБЩАЕМ САМИ. Доводчик при упоре в предел переводит цель в
|
|
730
|
+
// blocked — и делает это молча: снаружи автономный цикл просто перестаёт
|
|
731
|
+
// просыпаться. Молчаливый останов неотличим от поломки, поэтому говорим в
|
|
732
|
+
// тот канал, из которого цель ставили, и ровно один раз.
|
|
690
733
|
if (event.type === 'goal/change') {
|
|
691
734
|
const origin = goalOrigin.get(sid);
|
|
692
735
|
if (origin) {
|
|
693
736
|
try {
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
737
|
+
// 🔴 БЫЛО МОЛЧАЛИВО: `ctx.get('goals')?.get(...)` при отсутствии
|
|
738
|
+
// сервиса возвращал undefined, и сообщение об исходе цели просто не
|
|
739
|
+
// уходило — ровно то молчание, от которого этот блок и заведён.
|
|
740
|
+
// Сервис здесь обязан быть: goalOrigin заполняет только goalCommand,
|
|
741
|
+
// а он без ctx.get('goals') не проходит. Значит пустота означает, что
|
|
742
|
+
// сервис ПРОПАЛ, и это событие само по себе стоит строки.
|
|
697
743
|
const goalsSvc = ctx.get('goals');
|
|
698
744
|
if (!goalsSvc) {
|
|
699
745
|
log(`[goal] 🔴 сервис целей исчез — исход цели по сессии ${sid} не сообщён`);
|
|
@@ -742,7 +788,7 @@ export function apply(ctx, config = {}) {
|
|
|
742
788
|
}
|
|
743
789
|
} else if (event.type === 'assistant/message'
|
|
744
790
|
&& (turnAsk.get(sid)?.origin ?? lastOrigin.get(sid) ?? (chatId === A2A_CHAT ? 'a2a' : 'tg')) === 'a2a') {
|
|
745
|
-
// ответ
|
|
791
|
+
// ответ для Забавы — в файл, Telegram тут ни при чём
|
|
746
792
|
const blocks = event.data?.message?.content ?? [];
|
|
747
793
|
// 🔴 МАРКЕР ВЫРЕЗАЕМ ИЗ ВСЕГО, ЧТО УХОДИТ НАРУЖУ — из доставляемого
|
|
748
794
|
// текста и из копий. Он служебный: его дело остановить цикл, а не
|
|
@@ -755,7 +801,7 @@ export function apply(ctx, config = {}) {
|
|
|
755
801
|
fs.mkdirSync(A2A_OUT, { recursive: true });
|
|
756
802
|
fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}.txt`), replyHeader(sid) + text);
|
|
757
803
|
{ const c = copyTo('a2a'); if (c) sendCopy(c, sid, text); }
|
|
758
|
-
log(`[a2a] ответ
|
|
804
|
+
log(`[a2a] ответ Забаве записан (${text.length} знаков)`);
|
|
759
805
|
} catch (e) { log(`[a2a] не смог записать ответ: ${e?.message ?? e}`); }
|
|
760
806
|
}
|
|
761
807
|
} else if (event.type === 'assistant/message') {
|
|
@@ -793,7 +839,7 @@ export function apply(ctx, config = {}) {
|
|
|
793
839
|
return tmp;
|
|
794
840
|
}
|
|
795
841
|
|
|
796
|
-
//
|
|
842
|
+
// Транскрибирует голосовое сообщение через нашу локальную цепочку.
|
|
797
843
|
// Возвращает строку с текстом или null при ошибке.
|
|
798
844
|
async function transcribeVoice(fileId) {
|
|
799
845
|
let tmpAudio = null;
|
|
@@ -901,10 +947,16 @@ export function apply(ctx, config = {}) {
|
|
|
901
947
|
const routeKey = (MERGE_CHAT && String(chatId) === MERGE_CHAT) ? A2A_CHAT : chatId;
|
|
902
948
|
const { handle, sessionId } = await agentFor(routeKey);
|
|
903
949
|
tgChatFor.set(sessionId, chatId);
|
|
904
|
-
// 🔴
|
|
905
|
-
// followup объявлен в описании типов,
|
|
906
|
-
//
|
|
907
|
-
//
|
|
950
|
+
// 🔴 ЗДЕСЬ send(), А НЕ followup() — но причина СМЕНИЛАСЬ, читай внимательно.
|
|
951
|
+
// Было (0.1.0-rc.7): followup объявлен в описании типов, а В КОДЕ ЕГО НЕТ —
|
|
952
|
+
// вызов молча не делал ничего, снаружи «бот принял сообщение и замолчал».
|
|
953
|
+
// Стало (проверено на установленной 0.1.1-rc.2, 21.08.2026): followup в коде
|
|
954
|
+
// ЕСТЬ и он ровно наш вызов — dsh-agent-loop/lib/index.js:396
|
|
955
|
+
// followup(input) { this.send(input, "next-turn", true) }
|
|
956
|
+
// То есть обход больше не обход, а тот же самый путь, записанный явно.
|
|
957
|
+
// Оставляем send(): он работает на обеих версиях. Копии модуля разведены
|
|
958
|
+
// 22.08.2026 по агентам, но версии платформ у них разные (0.1.1-rc.2 и
|
|
959
|
+
// 0.1.0-rc.8), и код обязан работать на обеих.
|
|
908
960
|
// next-turn — обычный следующий ход;
|
|
909
961
|
// true — разбудить исполнителя, иначе сообщение будет ждать вечно.
|
|
910
962
|
lastOrigin.set(sessionId, 'tg');
|
|
@@ -941,8 +993,8 @@ export function apply(ctx, config = {}) {
|
|
|
941
993
|
// пользователя, а бот боту не пишет — так устроен мессенджер. Поэтому связь
|
|
942
994
|
// через каталог обмена, он же работает поверх ssh с любой машины фермы.
|
|
943
995
|
//
|
|
944
|
-
// входящие мне:
|
|
945
|
-
// исходящие ей:
|
|
996
|
+
// входящие мне: <дом агента>/a2a/in/*.txt — кладёт координатор, я передаю агенту
|
|
997
|
+
// исходящие ей: <дом агента>/a2a/out/*.txt — пишет агент, забирает координатор
|
|
946
998
|
// Каталог обмена задаётся настройкой: у каждого агента он свой. Не задан —
|
|
947
999
|
// межагентского канала у этого агента просто нет, и опрос не ведётся вовсе
|
|
948
1000
|
// (молчаливое создание чужих каталогов было бы хуже отсутствия связи).
|
|
@@ -951,6 +1003,54 @@ export function apply(ctx, config = {}) {
|
|
|
951
1003
|
const A2A_OUT = A2A_DIR ? path.join(A2A_DIR, 'out') : null;
|
|
952
1004
|
const A2A_CHAT = config.a2aSession || 'a2a'; // отдельная сессия, не смешивается с Telegram
|
|
953
1005
|
|
|
1006
|
+
// ── ВТОРОЙ ИСТОЧНИК ВХОДЯЩИХ: ЯЩИК ШИНЫ A2A (31.08.2026) ────────────────────
|
|
1007
|
+
// Шина между агентами машины: отправитель кладёт письмо в щель
|
|
1008
|
+
// /var/spool/a2a/<получатель>/ (каталог 1730: опустить можно, прочесть нельзя),
|
|
1009
|
+
// почтальон a2a-pochtalon раз в 5 с переносит его в собственность получателя,
|
|
1010
|
+
// а забрать из ящика и подать агенту должен ЭТОТ модуль.
|
|
1011
|
+
//
|
|
1012
|
+
// 🔴 ЗАЧЕМ ЗАВЕДЕНО: без этого блока выход шины не подключён никуда. Замер
|
|
1013
|
+
// 31.08.2026: три письма соседа пролежали в ящике ДВОЕ СУТОК и дошли рукой
|
|
1014
|
+
// координатора, а не шиной. Снаружи это выглядело как «агент не отвечает».
|
|
1015
|
+
//
|
|
1016
|
+
// Путь — НАСТРОЙКА, не константа: у каждого агента ящик свой, и требование
|
|
1017
|
+
// владельца от 31.08.2026 — всё управляемое вынести из кода. Не задан — звена
|
|
1018
|
+
// нет вовсе, опрос не ведётся (молчаливое чтение чужого ящика было бы хуже).
|
|
1019
|
+
const SPOOL_IN = config.spoolDir || null;
|
|
1020
|
+
// Порог пачки: до него письма подаются по одному (ход на письмо сохраняет
|
|
1021
|
+
// структуру разговора), сверх него — ОДНИМ ходом, чтобы накопленное не
|
|
1022
|
+
// превратилось в лавину подряд идущих ходов. Правило координатора 31.08.2026.
|
|
1023
|
+
const SPOOL_BATCH = Number(config.spoolBatchCount ?? 5);
|
|
1024
|
+
// Старше этого — подаётся с ЯВНОЙ пометкой возраста в тексте. Письмо
|
|
1025
|
+
// двухсуточной давности, поданное как свежее, толкает отвечать на устаревшее.
|
|
1026
|
+
const SPOOL_STALE_HOURS = Number(config.spoolStaleHours ?? 12);
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Отправитель — из ИМЕНИ файла: <метка>-<отправитель>-<случайное>.txt.
|
|
1030
|
+
* Так решил почтальон: содержимое письма — сырой текст без заголовков, и
|
|
1031
|
+
* подделать имя отправитель не может (имя формирует a2a-send, а каталог
|
|
1032
|
+
* закрыт на перечисление). Не разобралось — говорим прямо, а не молчим.
|
|
1033
|
+
*/
|
|
1034
|
+
function spoolSender(name) {
|
|
1035
|
+
const m = /^\d{8}T\d{6}Z-([^-]+)-/.exec(name);
|
|
1036
|
+
return m ? m[1] : '(имя не разобрано)';
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Возраст письма в часах. Источник — метка в ИМЕНИ (её ставит отправитель),
|
|
1041
|
+
* а не mtime: почтальон копирует файл, и при потере метки времени mtime
|
|
1042
|
+
* оказался бы временем переноса, то есть свежим у двухсуточного письма.
|
|
1043
|
+
* Метка не разобралась — берём mtime как нижнюю оценку и говорим об этом.
|
|
1044
|
+
*/
|
|
1045
|
+
function spoolAgeHours(name, full) {
|
|
1046
|
+
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-/.exec(name);
|
|
1047
|
+
if (m) {
|
|
1048
|
+
const t = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
|
|
1049
|
+
return (Date.now() - t) / 3600000;
|
|
1050
|
+
}
|
|
1051
|
+
try { return (Date.now() - fs.statSync(full).mtimeMs) / 3600000; } catch { return 0; }
|
|
1052
|
+
}
|
|
1053
|
+
|
|
954
1054
|
// Берём простые текстовые форматы. Всё остальное НЕ берём — но и не
|
|
955
1055
|
// проглатываем: имя отвергнутого файла называем в журнале. Поймано
|
|
956
1056
|
// 21.08.2026: письмо с расширением .md пролежало во входящих 26 минут,
|
|
@@ -998,7 +1098,7 @@ export function apply(ctx, config = {}) {
|
|
|
998
1098
|
// который нам не по правам (например 600 root:root), молча оставался
|
|
999
1099
|
// лежать во входящих. Снаружи это «агент не отвечает», а на деле
|
|
1000
1100
|
// мы даже не смогли прочитать вопрос. Поймано 18.08.2026.
|
|
1001
|
-
log(`[a2a] 🔴 не
|
|
1101
|
+
log(`[a2a] 🔴 не смогла прочитать ${f}: ${e?.message ?? e} (права: файл должен принадлежать владельцу агента и быть читаемым, chmod 644)`);
|
|
1002
1102
|
continue;
|
|
1003
1103
|
}
|
|
1004
1104
|
if (!text) continue;
|
|
@@ -1010,7 +1110,7 @@ export function apply(ctx, config = {}) {
|
|
|
1010
1110
|
if (!goalA2ASenders.has(sender)) {
|
|
1011
1111
|
log(`[goal] ОТКАЗ служебному каналу: отправитель ${sender ?? '(не назвался)'} не в списке goalA2ASenders`);
|
|
1012
1112
|
await sayTo('a2a', null, sender === undefined
|
|
1013
|
-
? 'Постановка цели из служебного канала требует заголовка первой строкой: "From:
|
|
1113
|
+
? 'Постановка цели из служебного канала требует заголовка первой строкой: "From: <инстанс>/<папка>".'
|
|
1014
1114
|
: `Отправитель ${sender} не вправе ставить цель.`);
|
|
1015
1115
|
} else {
|
|
1016
1116
|
try {
|
|
@@ -1038,7 +1138,25 @@ export function apply(ctx, config = {}) {
|
|
|
1038
1138
|
text = `[служебный канал, от координатора${sender ? ` ${sender}` : ''}]\n${stripMark(text)}`;
|
|
1039
1139
|
handle.agent.send(platform.createUserMessage({
|
|
1040
1140
|
content: [{ type: 'text', text }],
|
|
1041
|
-
|
|
1141
|
+
// 🔴 СВОЙ ВИД ИСТОЧНИКА ДЛЯ СЛУЖЕБНОГО КАНАЛА (21.08.2026, распоряжение
|
|
1142
|
+
// главной). Раньше тут стоял kind: 'user' — и это была НЕПРАВДА в слое
|
|
1143
|
+
// прав: платформа считала сообщение координатора человеческим, то есть
|
|
1144
|
+
// право ставить цель добывалось подлогом метки, а не записанным решением.
|
|
1145
|
+
// Теперь журнал честен: видно, что писал агент-координатор, а не человек.
|
|
1146
|
+
// ЧТО ЭТИМ ТЕРЯЕМ — обход ВСЕХ потребителей kind === 'user' в дереве
|
|
1147
|
+
// платформы (31 совпадение, разобраны все):
|
|
1148
|
+
// tool-goal hasDirectHumanInput -> false: из этого канала нельзя
|
|
1149
|
+
// закрыть цель родным инструментом (он у нас погашен);
|
|
1150
|
+
// tool-jobs бюджет подряд идущих пробуждений больше не обнуляется;
|
|
1151
|
+
// tool-skill жест /имя отсюда не подтянет тело скилла;
|
|
1152
|
+
// session-title заголовок сессии по такому сообщению не строится;
|
|
1153
|
+
// session-reference сообщение вне проекции «текущей поверхности».
|
|
1154
|
+
// Право ставить цель координатору вернём ЯВНЫМ списком видов в мосту —
|
|
1155
|
+
// решением, которое видно тому, кто откроет список.
|
|
1156
|
+
// Вид произвольный по устройству: createMessage кладёт source как есть,
|
|
1157
|
+
// схемы нет; 'goal' и 'skill-catalog' — такие же добавленные виды.
|
|
1158
|
+
// ⚠️ Личный чат владельца остаётся 'user' (строка 560) — он и есть человек.
|
|
1159
|
+
source: { kind: 'a2a' },
|
|
1042
1160
|
}), 'next-turn', true);
|
|
1043
1161
|
nudgeUntilClaimed(handle.agent, '[a2a]');
|
|
1044
1162
|
// 🔴 УДАЛЯЕМ ТОЛЬКО ПОСЛЕ УСПЕШНОЙ ПЕРЕДАЧИ (19.08.2026). Раньше файл
|
|
@@ -1052,10 +1170,115 @@ export function apply(ctx, config = {}) {
|
|
|
1052
1170
|
}
|
|
1053
1171
|
}
|
|
1054
1172
|
|
|
1173
|
+
/**
|
|
1174
|
+
* Забрать письма из ящика шины и подать агенту.
|
|
1175
|
+
*
|
|
1176
|
+
* 🔴 ДВА МЕСТА ОДНОЙ ДОСТАВКИ. Это короткий повтор того, что делает pollA2A;
|
|
1177
|
+
* общий кусок не выносила намеренно — в pollA2A он переплетён с разбором
|
|
1178
|
+
* /goal, и хелпер вышел бы дороже двух коротких кусков. Но перечень-копия
|
|
1179
|
+
* молча расходится: правишь ОДНО — проверь ВТОРОЕ. За этим следит
|
|
1180
|
+
* stend-yashchika-shiny.sh (сверяет, что оба места делают одно).
|
|
1181
|
+
*
|
|
1182
|
+
* Команд (/goal) из ящика НЕ принимаем: право ставить цель поимённое и живёт
|
|
1183
|
+
* у координатора прямым каналом. Второй путь к тому же действию всегда
|
|
1184
|
+
* расходится с первым, и по журналу перестаёт быть видно, кто поставил цель.
|
|
1185
|
+
*/
|
|
1186
|
+
async function pollSpool() {
|
|
1187
|
+
if (!SPOOL_IN) return;
|
|
1188
|
+
let all = [];
|
|
1189
|
+
try {
|
|
1190
|
+
all = fs.readdirSync(SPOOL_IN).sort();
|
|
1191
|
+
} catch (e) {
|
|
1192
|
+
// Одна строка на причину, не на прогон: опрос идёт каждые ~25 с.
|
|
1193
|
+
const key = `SPOOL:${e?.code ?? 'ERR'}`;
|
|
1194
|
+
if (!a2aReported.has(key)) {
|
|
1195
|
+
a2aReported.add(key);
|
|
1196
|
+
log(`[ящик] \u{1F534} недоступен (${SPOOL_IN}): ${e?.message ?? e}`);
|
|
1197
|
+
}
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const files = all.filter((f) => A2A_EXT.some((x) => f.endsWith(x)));
|
|
1201
|
+
for (const f of all) {
|
|
1202
|
+
if (files.includes(f) || a2aReported.has(`SP:${f}`)) continue;
|
|
1203
|
+
a2aReported.add(`SP:${f}`);
|
|
1204
|
+
log(`[ящик] \u{1F534} НЕ ВЗЯТ файл ${f}: беру только ${A2A_EXT.join(', ')}. `
|
|
1205
|
+
+ `Он так и будет лежать в ящике — переименуй отправителю`);
|
|
1206
|
+
}
|
|
1207
|
+
for (const name of [...a2aReported]) {
|
|
1208
|
+
if (name.startsWith('SP:') && !all.includes(name.slice(3))) a2aReported.delete(name);
|
|
1209
|
+
}
|
|
1210
|
+
if (!files.length) return; // пустой ящик — тишина, а не строка «пусто»
|
|
1211
|
+
|
|
1212
|
+
// Читаем всё, что взяли, и только потом решаем: по одному или пачкой.
|
|
1213
|
+
const pisma = [];
|
|
1214
|
+
for (const f of files) {
|
|
1215
|
+
const full = path.join(SPOOL_IN, f);
|
|
1216
|
+
let text = '';
|
|
1217
|
+
try {
|
|
1218
|
+
text = fs.readFileSync(full, 'utf-8').trim();
|
|
1219
|
+
} catch (e) {
|
|
1220
|
+
log(`[ящик] \u{1F534} не смогла прочитать ${f}: ${e?.message ?? e} `
|
|
1221
|
+
+ `(письмо должно принадлежать владельцу агента; переносит почтальон)`);
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
if (!text) { try { fs.unlinkSync(full); } catch { /* пустое — убрать */ } continue; }
|
|
1225
|
+
const age = spoolAgeHours(f, full);
|
|
1226
|
+
const stale = age >= SPOOL_STALE_HOURS
|
|
1227
|
+
? `\n[письмо пролежало в ящике ${Math.round(age)} ч — возможно, оно устарело]`
|
|
1228
|
+
: '';
|
|
1229
|
+
pisma.push({ f, full, who: spoolSender(f), text: stripMark(text), stale });
|
|
1230
|
+
}
|
|
1231
|
+
if (!pisma.length) return;
|
|
1232
|
+
|
|
1233
|
+
const packoj = pisma.length > SPOOL_BATCH;
|
|
1234
|
+
const partii = packoj ? [pisma] : pisma.map((p) => [p]);
|
|
1235
|
+
for (const partiya of partii) {
|
|
1236
|
+
const who = partiya.length === 1 ? partiya[0].who : 'агентов машины';
|
|
1237
|
+
const body = partiya.length === 1
|
|
1238
|
+
? `[служебный канал, от агента ${partiya[0].who}]${partiya[0].stale}\n${partiya[0].text}`
|
|
1239
|
+
: `[служебный канал, ${partiya.length} писем из ящика одной пачкой]\n\n`
|
|
1240
|
+
+ partiya.map((p) => `── от агента ${p.who}${p.stale}\n${p.text}`).join('\n\n');
|
|
1241
|
+
try {
|
|
1242
|
+
const { handle, sessionId: a2aSessionId } = await agentFor(A2A_CHAT);
|
|
1243
|
+
lastOrigin.set(a2aSessionId, 'a2a');
|
|
1244
|
+
pushAsk(a2aSessionId, { origin: 'a2a', who, q: partiya[0].text });
|
|
1245
|
+
copyAsk('a2a', who, body);
|
|
1246
|
+
handle.agent.send(platform.createUserMessage({
|
|
1247
|
+
content: [{ type: 'text', text: body }],
|
|
1248
|
+
// Тот же вид источника, что у служебного канала: письмо агента — не
|
|
1249
|
+
// слово человека. Разбор последствий kind !== 'user' — в pollA2A выше.
|
|
1250
|
+
source: { kind: 'a2a' },
|
|
1251
|
+
}), 'next-turn', true);
|
|
1252
|
+
nudgeUntilClaimed(handle.agent, '[ящик]');
|
|
1253
|
+
// 🔴 УДАЛЯЕМ ТОЛЬКО ПОСЛЕ УСПЕШНОЙ ПЕРЕДАЧИ, но удаляем ОБЯЗАТЕЛЬНО:
|
|
1254
|
+
// модуль опрашивает ящик каждые ~25 с, и неудалённое письмо подавалось
|
|
1255
|
+
// бы снова и снова. Не удалить — не «сохранить улику», а завести лавину.
|
|
1256
|
+
for (const p of partiya) { try { fs.unlinkSync(p.full); } catch { /* уже убрали */ } }
|
|
1257
|
+
log(`[ящик] принято ${partiya.length} письмо(писем) от ${who}, ${body.length} знаков`);
|
|
1258
|
+
} catch (e) {
|
|
1259
|
+
log(`[ящик] \u{1F534} не удалось передать агенту: ${e?.message ?? e} — `
|
|
1260
|
+
+ `${partiya.map((p) => p.f).join(', ')} ОСТАВЛЕНЫ в ящике, попробую снова`);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// 🔴 fetch failed СКРЫВАЕТ причину (ECONNRESET/ETIMEDOUT/ENOTFOUND выглядят
|
|
1266
|
+
// одинаково). Печатаем e.cause рядом с e.message. ЦЕНА: не всякая ошибка несёт
|
|
1267
|
+
// cause — тогда пишем «не указана», а не пустое место (пустое читается как
|
|
1268
|
+
// «причины нет», и это та же слепота, что и раньше).
|
|
1269
|
+
// Перенесено из второй живой копии модуля 01.09.2026 ДОСЛОВНО: своя редакция
|
|
1270
|
+
// того же лечения развела бы копии по логике при сверке.
|
|
1271
|
+
function errDetail(e) {
|
|
1272
|
+
const c = e?.cause;
|
|
1273
|
+
if (!c) return 'не указана';
|
|
1274
|
+
return c?.message ?? c?.code ?? String(c);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1055
1277
|
async function loop(run) {
|
|
1056
1278
|
while (run.alive) {
|
|
1057
1279
|
try {
|
|
1058
1280
|
await pollA2A();
|
|
1281
|
+
await pollSpool();
|
|
1059
1282
|
const updates = await tg.poll(25);
|
|
1060
1283
|
for (const u of updates) {
|
|
1061
1284
|
try { await handleUpdate(u); }
|
|
@@ -1063,7 +1286,7 @@ export function apply(ctx, config = {}) {
|
|
|
1063
1286
|
}
|
|
1064
1287
|
} catch (e) {
|
|
1065
1288
|
// Сеть моргнула или Telegram ответил ошибкой — ждём и продолжаем.
|
|
1066
|
-
log(`опрос не удался: ${e?.message ?? e}`);
|
|
1289
|
+
log(`опрос не удался: ${e?.message ?? e} | причина: ${errDetail(e)}`);
|
|
1067
1290
|
await new Promise((r) => setTimeout(r, 3000));
|
|
1068
1291
|
}
|
|
1069
1292
|
}
|