dsh-telegram-multiagent 1.0.1 → 1.1.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 +37 -0
- package/package.json +2 -2
- package/src/index.js +165 -10
package/README.md
CHANGED
|
@@ -105,3 +105,40 @@ Written for our own fleet and running in production on several agents. The harne
|
|
|
105
105
|
they carry the reasoning behind each non-obvious line, and a translation is welcome.
|
|
106
106
|
|
|
107
107
|
MIT.
|
|
108
|
+
|
|
109
|
+
## 1.1.0 — кто спросил, кому отвечено, и кто это видит
|
|
110
|
+
|
|
111
|
+
Три возможности, выросшие из одной задачи: с агентом работают ДВОЕ — владелец из личного чата и
|
|
112
|
+
координатор по служебному каналу, — и они должны делить одну память, не путаясь, кто есть кто.
|
|
113
|
+
|
|
114
|
+
**Пометка отправителя, которую нельзя подделать.** Каждое входящее помечается каналом, из которого
|
|
115
|
+
пришло. 🔴 Ключевое: перед тем как поставить свою пометку, модуль ВЫРЕЗАЕТ из текста всё похожее на
|
|
116
|
+
неё. Без этого пометка была бы подписью в тексте, а подпись подделывает любой, кто умеет печатать.
|
|
117
|
+
Проверено нападением: сообщение из личного чата с готовой строкой «служебный канал» приходит
|
|
118
|
+
агенту помеченным как личный чат.
|
|
119
|
+
|
|
120
|
+
**Общая память двух собеседников.** `mergeChatIntoA2A: <идентификатор чата>` — сообщения этого чата
|
|
121
|
+
идут не в свою сессию, а в служебную. Один агент, одна история разговора, два различаемых лица.
|
|
122
|
+
Побочно лечит столкновение имён инструментов: второй экземпляр набора не монтируется вовсе.
|
|
123
|
+
|
|
124
|
+
**Адресат ответа привязан к ХОДУ, а не к последнему сообщению.** Ответ помечается `[ответ: кто]` с
|
|
125
|
+
цитатой вопроса. 🔴 Почему не «отвечаем последнему спросившему»: если второй вопрос пришёл, пока
|
|
126
|
+
первый считается, ответы разъезжаются не тем — причём с правильными на вид пометками.
|
|
127
|
+
|
|
128
|
+
**Режим доставки — в файле ВНЕ кода.** Путь берётся из `settingsFile` (по умолчанию — файл токена с
|
|
129
|
+
расширением `.json`). Читается на лету по времени изменения: правка действует со следующего
|
|
130
|
+
сообщения, перезапуск не нужен. Файл переживает обновление модуля.
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{ "deliveryMode": "personal" }
|
|
134
|
+
```
|
|
135
|
+
| режим | кто что видит |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `personal` (умолчание) | каждый видит только свои вопросы и ответы |
|
|
138
|
+
| `broadcast` | оба видят весь обмен, чужое помечено «адресовано не вам» |
|
|
139
|
+
| `owner-all` | владелец видит всё, координатор только своё |
|
|
140
|
+
|
|
141
|
+
🔴 Умолчание выбрано самым тихим намеренно: испорченный или недоступный файл настроек НЕ должен
|
|
142
|
+
внезапно раскрыть переписку в чужой канал. Ошибка чтения = `personal`, и о ней говорится в журнал.
|
|
143
|
+
|
|
144
|
+
Копируются и вопросы, и ответы: половина разговора без второй половины нечитаема.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-telegram-multiagent",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Telegram channel for DeepSeek Harness — one shared module serving several agents
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Telegram channel for DeepSeek Harness — one shared module serving several agents, with unforgeable sender marks, merged owner+coordinator memory, and a delivery mode you switch in a file outside the code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"files": [
|
package/src/index.js
CHANGED
|
@@ -99,6 +99,7 @@ export function apply(ctx, config = {}) {
|
|
|
99
99
|
// Имя агента — только для журнала: при нескольких ботах на машине нужно
|
|
100
100
|
// видеть, чья строка. К логике отношения не имеет.
|
|
101
101
|
const who = config.agentName || 'telegram';
|
|
102
|
+
const who_ = config.agentName || 'агент'; // для текстов, видимых собеседнику
|
|
102
103
|
const log = (m) => console.error(`[${who}] ${m}`);
|
|
103
104
|
|
|
104
105
|
const token = readTokenFrom(config, log);
|
|
@@ -114,6 +115,92 @@ export function apply(ctx, config = {}) {
|
|
|
114
115
|
|
|
115
116
|
const tg = new TelegramClient(token, log);
|
|
116
117
|
const chats = new Map(); // chatId → { handle, sessionId }
|
|
118
|
+
// 🔴 ОТКУДА ПРИШЁЛ ПОСЛЕДНИЙ ВОПРОС (20.08.2026). Владелец захотел общаться с
|
|
119
|
+
// ОДНИМ экземпляром агента, а не с двумя копиями: раньше чат в мессенджере и
|
|
120
|
+
// межагентский канал были РАЗНЫМИ сессиями, то есть буквально двумя агентами
|
|
121
|
+
// с разной памятью разговора. Теперь их можно свести в одну сессию — но тогда
|
|
122
|
+
// ответ надо возвращать туда, откуда пришёл вопрос, иначе ответ координатору
|
|
123
|
+
// улетит в чат владельца. Ключ — сессия, значение — 'a2a' или 'tg'.
|
|
124
|
+
const lastOrigin = new Map();
|
|
125
|
+
|
|
126
|
+
// 🔴 СЛИЯНИЕ КАНАЛОВ В ОДНУ ПАМЯТЬ (20.08.2026, решение Александра).
|
|
127
|
+
// mergeChatIntoA2A: <идентификатор чата> — сообщения этого чата попадают НЕ в
|
|
128
|
+
// свою сессию `telegram-<чат>`, а в служебную. Тогда владелец и координатор
|
|
129
|
+
// говорят с ОДНИМ агентом и одной памятью разговора, а различает он их по
|
|
130
|
+
// пометке канала (её ставит код доставки, подделать нельзя).
|
|
131
|
+
// Побочно это лечит столкновение: второй экземпляр набора не монтируется
|
|
132
|
+
// вовсе, а значит имя инструмента памяти не может оказаться занятым.
|
|
133
|
+
const MERGE_CHAT = config.mergeChatIntoA2A ? String(config.mergeChatIntoA2A) : null;
|
|
134
|
+
// Куда отвечать в мессенджер, если сессия общая: sessionId → числовой чат.
|
|
135
|
+
const tgChatFor = new Map();
|
|
136
|
+
|
|
137
|
+
// 🔴 КОМУ АДРЕСОВАН ОТВЕТ (21.08.2026, задача Александра).
|
|
138
|
+
// Пометка на ВХОДЕ решает «кто спросил», но не решает «кому отвечено»: когда в
|
|
139
|
+
// одной памяти сидят двое, ответ без адреса читается обоими как свой. И хуже:
|
|
140
|
+
// маршрут «отвечаем последнему спросившему» ломается, если второй вопрос
|
|
141
|
+
// пришёл, пока первый ещё считается, — ответ уедет не тому.
|
|
142
|
+
// Поэтому происхождение привязано к ХОДУ, а не к последнему сообщению: на
|
|
143
|
+
// входе кладём в очередь, на turn/start снимаем, ответ метим тем, что сняли.
|
|
144
|
+
// ── НАСТРОЙКА ДОСТАВКИ, ОТДЕЛЬНЫМ ФАЙЛОМ (21.08.2026, задача Александра)
|
|
145
|
+
//
|
|
146
|
+
// Файл живёт ВНЕ модуля и переживает его обновление: код можно переписать,
|
|
147
|
+
// перевыпустить, поставить заново — настройка останется. Читается на лету,
|
|
148
|
+
// по времени изменения: поправил файл — следующий ответ уже по-новому,
|
|
149
|
+
// перезапуск не нужен.
|
|
150
|
+
//
|
|
151
|
+
// { "deliveryMode": "personal" | "broadcast" | "owner-all" }
|
|
152
|
+
// personal — каждый видит только свои ответы (по умолчанию);
|
|
153
|
+
// broadcast — оба видят всё, с пометкой кому адресовано;
|
|
154
|
+
// owner-all — владелец видит всё, координатор только своё.
|
|
155
|
+
//
|
|
156
|
+
// 🔴 Умолчание выбрано самым тихим: сломанный или пустой файл НЕ должен
|
|
157
|
+
// внезапно раскрывать переписку в чужой канал. Ошибка чтения = personal,
|
|
158
|
+
// и о ней говорим в журнал ГРОМКО, а не молча.
|
|
159
|
+
const SETTINGS_FILE = config.settingsFile
|
|
160
|
+
|| (config.tokenFile ? String(config.tokenFile).replace(/\.token$/, '.json') : null);
|
|
161
|
+
let _setCache = { mtime: 0, data: {} };
|
|
162
|
+
function settings() {
|
|
163
|
+
if (!SETTINGS_FILE) return {};
|
|
164
|
+
try {
|
|
165
|
+
const st = fs.statSync(SETTINGS_FILE);
|
|
166
|
+
if (st.mtimeMs !== _setCache.mtime) {
|
|
167
|
+
_setCache = { mtime: st.mtimeMs, data: JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) };
|
|
168
|
+
log(`настройки перечитаны из ${SETTINGS_FILE}: режим доставки = ${_setCache.data.deliveryMode ?? 'personal (умолчание)'}`);
|
|
169
|
+
}
|
|
170
|
+
return _setCache.data ?? {};
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (_setCache.mtime !== -1) { log(`🔴 настройки не прочитаны (${SETTINGS_FILE}): ${e?.message ?? e} — работаю в режиме personal`); _setCache = { mtime: -1, data: {} }; }
|
|
173
|
+
return {};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const deliveryMode = () => {
|
|
177
|
+
const m = settings().deliveryMode;
|
|
178
|
+
return (m === 'broadcast' || m === 'owner-all') ? m : 'personal';
|
|
179
|
+
};
|
|
180
|
+
/** Нужна ли копия во ВТОРОЙ канал при ответе, адресованном origin. */
|
|
181
|
+
const copyTo = (origin) => {
|
|
182
|
+
const m = deliveryMode();
|
|
183
|
+
if (m === 'broadcast') return origin === 'a2a' ? 'tg' : 'a2a';
|
|
184
|
+
if (m === 'owner-all') return origin === 'a2a' ? 'tg' : null; // владельцу копию чужого
|
|
185
|
+
return null;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const pendingAsk = new Map(); // sessionId → очередь {origin, who, q}
|
|
189
|
+
const turnAsk = new Map(); // sessionId → чей ход считается сейчас
|
|
190
|
+
const pushAsk = (sid, ask) => { const q = pendingAsk.get(sid) ?? []; q.push(ask); pendingAsk.set(sid, q); };
|
|
191
|
+
const quote = (s) => { const one = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
192
|
+
return one.length > 60 ? one.slice(0, 60) + '…' : one; };
|
|
193
|
+
const replyHeader = (sid) => { const a = turnAsk.get(sid);
|
|
194
|
+
return a ? `[ответ: ${a.who}] на «${quote(a.q)}»\n\n` : ''; };
|
|
195
|
+
|
|
196
|
+
// 🔴 ЧТОБЫ ПОМЕТКЕ МОЖНО БЫЛО ВЕРИТЬ, ЕЁ НАДО СНАЧАЛА ВЫРЕЗАТЬ (20.08.2026).
|
|
197
|
+
// Пометку ставит код доставки — но если во входящем тексте УЖЕ есть строка
|
|
198
|
+
// такого вида, в сообщении окажется две пометки, и вторая (чужая, напечатанная
|
|
199
|
+
// руками) будет выглядеть так же убедительно. Поэтому: сперва удаляем из текста
|
|
200
|
+
// всё похожее на пометку, потом ставим свою. Тогда единственный, кто может её
|
|
201
|
+
// написать, — этот модуль, и подделать её, напечатав, нельзя.
|
|
202
|
+
const ORIGIN_MARK = /^\[\s*(?:служебный канал|личный чат)[^\]]*\]\s*$/gim;
|
|
203
|
+
const stripMark = (s) => String(s).replace(ORIGIN_MARK, '').trimStart();
|
|
117
204
|
const sessionToChat = new Map(); // sessionId → chatId
|
|
118
205
|
|
|
119
206
|
/** Один чат — один агент. Создаём лениво, при первом сообщении. */
|
|
@@ -256,17 +343,59 @@ export function apply(ctx, config = {}) {
|
|
|
256
343
|
return entry;
|
|
257
344
|
}
|
|
258
345
|
|
|
346
|
+
/** Копия ВОПРОСА во второй канал (21.08.2026: владелец хочет видеть и входящие,
|
|
347
|
+
* а не только ответы — иначе видна половина разговора и она непонятна). */
|
|
348
|
+
function copyAsk(origin, who, q) {
|
|
349
|
+
const target = copyTo(origin);
|
|
350
|
+
if (!target) return;
|
|
351
|
+
const text = `📨 вопрос от: ${who}\n\n${q}`;
|
|
352
|
+
log(`[копия] вопрос от ${who} → ${target} (режим ${deliveryMode()})`);
|
|
353
|
+
if (target === 'tg') {
|
|
354
|
+
const chat = MERGE_CHAT ?? [...tgChatFor.values()][0];
|
|
355
|
+
if (chat) tg.send(chat, text)
|
|
356
|
+
.then(() => log(`[копия] вопрос доставлен в Telegram чат ${chat}`))
|
|
357
|
+
.catch((e) => log(`🔴 копия вопроса в Telegram не ушла: ${e?.message ?? e}`));
|
|
358
|
+
else log('копию вопроса в Telegram отправить некуда: чат владельца неизвестен');
|
|
359
|
+
} else if (A2A_OUT) {
|
|
360
|
+
try { fs.mkdirSync(A2A_OUT, { recursive: true }); fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}-ask.txt`), text); }
|
|
361
|
+
catch (e) { log(`🔴 копия вопроса в служебный канал не ушла: ${e?.message ?? e}`); }
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Копия ответа во второй канал — с пометкой, что это не тебе. */
|
|
366
|
+
function sendCopy(target, sid, body) {
|
|
367
|
+
const head = replyHeader(sid).trim();
|
|
368
|
+
const text = `📄 копия (адресовано не вам)\n${head}\n\n${body}`;
|
|
369
|
+
log(`[копия] ответ → ${target} (режим ${deliveryMode()})`);
|
|
370
|
+
if (target === 'tg') {
|
|
371
|
+
const chat = MERGE_CHAT ?? [...tgChatFor.values()][0];
|
|
372
|
+
if (chat) tg.send(chat, text)
|
|
373
|
+
.then(() => log(`[копия] ответ доставлен в Telegram чат ${chat}`))
|
|
374
|
+
.catch((e) => log(`🔴 копия в Telegram не ушла: ${e?.message ?? e}`));
|
|
375
|
+
else log('копию в Telegram отправить некуда: чат владельца неизвестен');
|
|
376
|
+
} else if (A2A_OUT) {
|
|
377
|
+
try { fs.mkdirSync(A2A_OUT, { recursive: true }); fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}-copy.txt`), text); }
|
|
378
|
+
catch (e) { log(`🔴 копия в служебный канал не ушла: ${e?.message ?? e}`); }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
259
382
|
// ── ВЫХОД: события сессии → сообщения в Telegram
|
|
260
383
|
ctx.on('session/event', (session, event) => {
|
|
261
384
|
// 🔴 DEBUG 2026-08-18: все типы событий
|
|
262
385
|
log(`[event] type=${event.type} session.id=${session?.id} knownSessions=${[...sessionToChat.keys()].join('|')}`);
|
|
263
|
-
const
|
|
386
|
+
const sid = String(session?.id ?? '');
|
|
387
|
+
// 🔴 При слиянии sessionToChat указывает на КЛЮЧ служебной сессии, а не на
|
|
388
|
+
// числовой чат — отправка по нему молча не дойдёт. Настоящий чат берём из
|
|
389
|
+
// карты, заполняемой на входе из мессенджера.
|
|
390
|
+
const chatId = tgChatFor.get(sid) ?? sessionToChat.get(sid);
|
|
264
391
|
if (chatId === undefined) {
|
|
265
392
|
if (event.type === 'assistant/message') log(`[event] chatId undefined для session ${session?.id} — игнорирую`);
|
|
266
393
|
return;
|
|
267
394
|
}
|
|
268
395
|
try {
|
|
269
396
|
if (event.type === 'turn/start') {
|
|
397
|
+
const q = pendingAsk.get(sid) ?? [];
|
|
398
|
+
if (q.length) turnAsk.set(sid, q.shift());
|
|
270
399
|
void tg.typing(chatId);
|
|
271
400
|
} else if (event.type === 'turn/end' && event.data?.reason?.kind === 'error') {
|
|
272
401
|
// 🔴 18.08.2026: ход может оборваться с внятной ошибкой, и она приходит
|
|
@@ -285,14 +414,16 @@ export function apply(ctx, config = {}) {
|
|
|
285
414
|
} else {
|
|
286
415
|
void tg.send(chatId, `Не смог выполнить ход: ${why}`);
|
|
287
416
|
}
|
|
288
|
-
} else if (event.type === 'assistant/message'
|
|
289
|
-
|
|
417
|
+
} else if (event.type === 'assistant/message'
|
|
418
|
+
&& (turnAsk.get(sid)?.origin ?? lastOrigin.get(sid) ?? (chatId === A2A_CHAT ? 'a2a' : 'tg')) === 'a2a') {
|
|
419
|
+
// ответ координатору — в файл, Telegram тут ни при чём
|
|
290
420
|
const blocks = event.data?.message?.content ?? [];
|
|
291
421
|
const text = blocks.filter((b) => b?.type === 'text').map((b) => b.text).join('\n').trim();
|
|
292
422
|
if (text) {
|
|
293
423
|
try {
|
|
294
424
|
fs.mkdirSync(A2A_OUT, { recursive: true });
|
|
295
|
-
fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}.txt`), text);
|
|
425
|
+
fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}.txt`), replyHeader(sid) + text);
|
|
426
|
+
{ const c = copyTo('a2a'); if (c) sendCopy(c, sid, text); }
|
|
296
427
|
log(`[a2a] ответ координатору записан (${text.length} знаков)`);
|
|
297
428
|
} catch (e) { log(`[a2a] не смог записать ответ: ${e?.message ?? e}`); }
|
|
298
429
|
}
|
|
@@ -306,7 +437,8 @@ export function apply(ctx, config = {}) {
|
|
|
306
437
|
.trim();
|
|
307
438
|
if (text) {
|
|
308
439
|
log(`[event] отправляю в Telegram chatId=${chatId} (${text.length} знаков)`);
|
|
309
|
-
|
|
440
|
+
{ const c = copyTo('tg'); if (c) sendCopy(c, sid, text); }
|
|
441
|
+
tg.send(chatId, replyHeader(sid) + text).catch((e) => log(`🔴 send к chatId=${chatId} не удался: ${e?.message ?? e}`));
|
|
310
442
|
}
|
|
311
443
|
}
|
|
312
444
|
} catch (e) {
|
|
@@ -332,7 +464,10 @@ export function apply(ctx, config = {}) {
|
|
|
332
464
|
let tmpAudio = null;
|
|
333
465
|
try {
|
|
334
466
|
tmpAudio = await downloadTelegramFile(fileId);
|
|
335
|
-
//
|
|
467
|
+
// transcribe-local-shared — наша цепочка: sage-corrector → transcribe-whispercpp-core → whisper-warm.service
|
|
468
|
+
// 🔴 Внешняя команда расшифровки — НАСТРОЙКА, а не зашитый путь: у каждого
|
|
469
|
+
// своя цепочка. Не задана — голосовые просто не расшифровываются, и об этом
|
|
470
|
+
// говорится в журнал, а не молча игнорируется.
|
|
336
471
|
if (!config.transcribeCommand) {
|
|
337
472
|
log('голосовые не расшифровываются: не задан config.transcribeCommand');
|
|
338
473
|
return null;
|
|
@@ -376,7 +511,7 @@ export function apply(ctx, config = {}) {
|
|
|
376
511
|
// Отказываем И поднимаем тревогу владельцу — молча отказывать нельзя:
|
|
377
512
|
// сам факт попытки это событие безопасности, а не бытовая мелочь.
|
|
378
513
|
const who = msg.from ?? {};
|
|
379
|
-
const alert = `🔴 Чужой написал
|
|
514
|
+
const alert = `🔴 Чужой написал агенту ${who_}\n` +
|
|
380
515
|
`id: ${userId}\n` +
|
|
381
516
|
`имя: ${who.first_name ?? '?'} ${who.last_name ?? ''}`.trim() + `\n` +
|
|
382
517
|
`ник: ${who.username ? '@' + who.username : 'нет'}\n` +
|
|
@@ -392,7 +527,7 @@ export function apply(ctx, config = {}) {
|
|
|
392
527
|
|
|
393
528
|
// Команды обрабатываем сами, до агента.
|
|
394
529
|
if (text === '/start' || text === '/help') {
|
|
395
|
-
await tg.send(chatId,
|
|
530
|
+
await tg.send(chatId, `${who_} на связи. Пишите задачу обычным сообщением.\n` +
|
|
396
531
|
'/new — начать разговор заново.');
|
|
397
532
|
return;
|
|
398
533
|
}
|
|
@@ -407,13 +542,23 @@ export function apply(ctx, config = {}) {
|
|
|
407
542
|
return;
|
|
408
543
|
}
|
|
409
544
|
|
|
410
|
-
|
|
545
|
+
// При включённом слиянии владелец попадает в ту же сессию, что и служебный
|
|
546
|
+
// канал: одна память на двоих. Ответ при этом обязан вернуться в мессенджер,
|
|
547
|
+
// поэтому запоминаем настоящий чат отдельно.
|
|
548
|
+
const routeKey = (MERGE_CHAT && String(chatId) === MERGE_CHAT) ? A2A_CHAT : chatId;
|
|
549
|
+
const { handle, sessionId } = await agentFor(routeKey);
|
|
550
|
+
tgChatFor.set(sessionId, chatId);
|
|
411
551
|
// 🔴 ТОЛЬКО send(), НЕ followup(). Проверено на установленной версии 0.1.0-rc.7:
|
|
412
552
|
// followup объявлен в описании типов, но В КОДЕ ЕГО НЕТ — вызов молча не делает
|
|
413
553
|
// ничего, и снаружи это выглядит как «бот принял сообщение и замолчал».
|
|
414
554
|
// Правильный вызов подсмотрен в самом продукте: this.send(input, "next-turn", true).
|
|
415
555
|
// next-turn — обычный следующий ход;
|
|
416
556
|
// true — разбудить исполнителя, иначе сообщение будет ждать вечно.
|
|
557
|
+
lastOrigin.set(sessionId, 'tg');
|
|
558
|
+
pushAsk(sessionId, { origin: 'tg', who: msg.from?.first_name ?? 'владелец', q: text });
|
|
559
|
+
copyAsk('tg', msg.from?.first_name ?? 'владелец', text);
|
|
560
|
+
// Симметрично: сообщение из мессенджера помечается как пришедшее из чата.
|
|
561
|
+
text = `[личный чат, от ${msg.from?.first_name ?? 'владельца'}]\n${stripMark(text)}`;
|
|
417
562
|
const userMsg = platform.createUserMessage({
|
|
418
563
|
content: [{ type: 'text', text }],
|
|
419
564
|
source: { kind: 'user' },
|
|
@@ -476,7 +621,17 @@ export function apply(ctx, config = {}) {
|
|
|
476
621
|
}
|
|
477
622
|
if (!text) continue;
|
|
478
623
|
try {
|
|
479
|
-
const { handle } = await agentFor(A2A_CHAT);
|
|
624
|
+
const { handle, sessionId: a2aSessionId } = await agentFor(A2A_CHAT);
|
|
625
|
+
lastOrigin.set(a2aSessionId, 'a2a');
|
|
626
|
+
pushAsk(a2aSessionId, { origin: 'a2a', who: 'координатор', q: text });
|
|
627
|
+
copyAsk('a2a', 'координатор', text);
|
|
628
|
+
// 🔴 ПОМЕТКУ СТАВИТ КАНАЛ, А НЕ ОТПРАВИТЕЛЬ (20.08.2026). Когда личный
|
|
629
|
+
// чат владельца и служебный канал сведены в ОДНУ сессию, агент не может
|
|
630
|
+
// отличить, кто говорит: подпись в тексте подделывается тривиально.
|
|
631
|
+
// Наш агент на этом верно упёрся — отказался выполнять просьбу,
|
|
632
|
+
// подписанную чужим именем, пришедшую не из своего канала. Значит
|
|
633
|
+
// происхождение обязан сообщать код доставки, которому подделать нечем.
|
|
634
|
+
text = `[служебный канал, от координатора]\n${stripMark(text)}`;
|
|
480
635
|
handle.agent.send(platform.createUserMessage({
|
|
481
636
|
content: [{ type: 'text', text }],
|
|
482
637
|
source: { kind: 'user' },
|