instar 1.3.52 → 1.3.54

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.
@@ -753,96 +753,230 @@ export class AgentServer {
753
753
  const adapter = this.telegramAdapter;
754
754
  if (!adapter)
755
755
  return;
756
- const cfg = this.getMenteeConfigSnapshot();
757
- if (!cfg.enabled)
758
- return;
759
- if (!cfg.localAgentName) {
760
- console.warn('[mentee] receiver wiring skipped — mentee.localAgentName required');
761
- return;
756
+ const self = this;
757
+ const ledger = this.getOrCreateA2aLedger();
758
+ const localAgent = this.config.projectName;
759
+ // The primary adapter's hook carries ALL a2a roles this agent accepts —
760
+ // both as-mentee (`mentor`) and as-mentor (`mentor-reply`). Same-machine
761
+ // a2a (Telegram blocks bot-to-bot) routes through /a2a/inbox → this hook.
762
+ const knownAgents = {};
763
+ const acceptRoles = {};
764
+ const roleHandlers = new Map();
765
+ // ── Mentee side: accept `mentor` from configured known-mentors ──
766
+ const menteeCfg = this.getMenteeConfigSnapshot();
767
+ const menteeReady = menteeCfg.enabled &&
768
+ !!menteeCfg.localAgentName &&
769
+ Object.keys(menteeCfg.knownMentors).length > 0 &&
770
+ !!menteeCfg.replyChatId &&
771
+ !!menteeCfg.replyTopicId;
772
+ if (menteeCfg.enabled && !menteeReady) {
773
+ console.warn('[mentee] receiver wiring skipped — mentee.enabled but missing localAgentName / knownMentors / replyChatId / replyTopicId');
762
774
  }
763
- if (Object.keys(cfg.knownMentors).length === 0) {
764
- console.warn('[mentee] receiver wiring skipped — mentee.knownMentors is empty');
765
- return;
775
+ if (menteeReady) {
776
+ for (const [mentorName, info] of Object.entries(menteeCfg.knownMentors)) {
777
+ knownAgents[mentorName] = info;
778
+ acceptRoles[mentorName] = [...(acceptRoles[mentorName] ?? []), 'mentor'];
779
+ }
780
+ const sessionTimeoutMs = menteeCfg.sessionTimeoutMs;
781
+ const mentorMessageHandler = async (msg, _ctx) => {
782
+ // Spawn a mentee session, bounded-wait, capture the reply. Capture the
783
+ // last non-empty pane snapshot WHILE the session is alive — the post-
784
+ // completion capture races the reaper (the session's tmux pane is gone
785
+ // by the time we detect completion, so captureOutput returns empty).
786
+ let reply = '';
787
+ let sessionId = '';
788
+ try {
789
+ const session = await self.sessionManager.spawnSession({
790
+ name: `mentee-handle-${Date.now()}`,
791
+ prompt: msg.body,
792
+ maxDurationMinutes: Math.max(1, Math.ceil(sessionTimeoutMs / 60_000)),
793
+ });
794
+ sessionId = session.id;
795
+ const tmux = session.tmuxSession;
796
+ const pollIntervalMs = 2_000;
797
+ const pollIterations = Math.max(1, Math.ceil(sessionTimeoutMs / pollIntervalMs));
798
+ let finished = false;
799
+ for (let i = 0; i < pollIterations; i++) {
800
+ const stillRunning = self.sessionManager
801
+ .listRunningSessions()
802
+ .some((s) => s.id === session.id);
803
+ // Capture-while-alive: keep the last non-empty snapshot so a
804
+ // completed-then-reaped session still yields its output.
805
+ const snapshot = self.sessionManager.captureOutput(tmux, 200) ?? '';
806
+ if (snapshot.trim())
807
+ reply = snapshot;
808
+ if (!stillRunning) {
809
+ finished = true;
810
+ break;
811
+ }
812
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
813
+ }
814
+ if (!finished) {
815
+ try {
816
+ self.sessionManager.killSession(session.id);
817
+ }
818
+ catch { /* best-effort */ }
819
+ console.warn(`[mentee] session ${session.id} timed out at ${sessionTimeoutMs}ms (corr=${msg.corr}); sending best-effort partial reply`);
820
+ }
821
+ }
822
+ catch (err) {
823
+ console.warn(`[mentee] mentor-message handler spawn failed (corr=${msg.corr}, sessionId=${sessionId}):`, err instanceof Error ? err.message : String(err));
824
+ return;
825
+ }
826
+ if (!reply.trim()) {
827
+ console.warn(`[mentee] mentee session produced empty reply for corr=${msg.corr}; skipping reply-out`);
828
+ return;
829
+ }
830
+ // Reply OUT via the a2a transport (same-machine /a2a/inbox when the
831
+ // mentor is a local peer; Telegram fallback otherwise). The handler
832
+ // stays capture-only — deliverA2aMessage is an orchestrator method,
833
+ // not a capability handed into the handler.
834
+ await self.deliverA2aMessage({
835
+ fromAgent: localAgent,
836
+ toAgent: msg.from,
837
+ role: 'mentor-reply',
838
+ corr: msg.corr || msg.id,
839
+ body: reply,
840
+ allowedRoles: new Set(['mentor-reply']),
841
+ telegramTopicId: menteeCfg.replyTopicId,
842
+ toBotId: menteeCfg.knownMentors[msg.from]?.botId,
843
+ });
844
+ };
845
+ roleHandlers.set('mentor', mentorMessageHandler);
766
846
  }
767
- if (!cfg.replyChatId || !cfg.replyTopicId) {
768
- console.warn('[mentee] receiver wiring skipped — mentee.replyChatId + mentee.replyTopicId required for reply path');
769
- return;
847
+ // ── Mentor side: accept `mentor-reply` from the configured mentee ──
848
+ // So a mentee's reply arriving via /a2a/inbox reaches the same
849
+ // finding-emission-only handler that installMentorReceiverHook uses on
850
+ // the mentor-BOT adapter (spec §250 — capability-handle, capture-only).
851
+ const mentorCfg = this.getMentorConfigSnapshot();
852
+ if (mentorCfg.menteeBotId) {
853
+ const menteeAgent = `instar-${mentorCfg.menteeFramework}`;
854
+ knownAgents[menteeAgent] = { botId: mentorCfg.menteeBotId };
855
+ acceptRoles[menteeAgent] = [...(acceptRoles[menteeAgent] ?? []), 'mentor-reply'];
856
+ const outstanding = this.getOrCreateMentorOutstanding();
857
+ const replyJsonl = path.join(this.config.stateDir, 'mentor-replies.jsonl');
858
+ this.mentorReplyJsonlPath = replyJsonl;
859
+ const mentorReplyHandler = async (msg, ctx) => {
860
+ const had = outstanding.clearByCorr(msg.corr);
861
+ if (!had) {
862
+ console.warn(`[mentor] mentor-reply for unknown corr=${msg.corr} (late after orphan-sweep?); persisting anyway`);
863
+ }
864
+ try {
865
+ fs.mkdirSync(path.dirname(replyJsonl), { recursive: true });
866
+ const row = {
867
+ ts: Date.now(),
868
+ fromAgent: msg.from,
869
+ corr: msg.corr,
870
+ replyId: msg.id,
871
+ topicId: ctx.topicId,
872
+ message: msg.body,
873
+ transport: 'a2a-inbox-local',
874
+ };
875
+ fs.appendFileSync(replyJsonl, JSON.stringify(row) + '\n', 'utf-8');
876
+ console.log(`[mentor] mentor-reply persisted (corr=${msg.corr}, from=${msg.from}) → mentor-replies.jsonl`);
877
+ }
878
+ catch (err) {
879
+ console.warn('[mentor] mentor-reply persist failed (non-fatal):', err instanceof Error ? err.message : String(err));
880
+ }
881
+ };
882
+ roleHandlers.set('mentor-reply', mentorReplyHandler);
770
883
  }
771
- const acceptRoles = {};
772
- for (const mentorName of Object.keys(cfg.knownMentors)) {
773
- acceptRoles[mentorName] = ['mentor'];
884
+ if (roleHandlers.size === 0) {
885
+ // Neither side configured — nothing to install. /a2a/inbox will return
886
+ // agentMessage:false until a role-handler exists.
887
+ return;
774
888
  }
775
889
  const recipientCfg = {
776
- localAgent: cfg.localAgentName,
777
- knownAgents: cfg.knownMentors,
890
+ localAgent,
891
+ knownAgents,
778
892
  acceptRoles,
779
893
  skewWindowMs: 24 * 60 * 60 * 1000,
780
894
  maxVersion: A2A_VERSION,
781
895
  };
782
- const self = this;
896
+ const hook = buildAgentMessageHook({
897
+ config: recipientCfg,
898
+ ledger,
899
+ processedIds: this.getOrCreateA2aProcessedIds(),
900
+ roleHandlers,
901
+ });
902
+ adapter.setAgentMessageHook(hook);
903
+ console.log(`[a2a] primary-adapter hook installed — localAgent=${localAgent}, roles=[${[...roleHandlers.keys()].join(',')}], knownAgents=[${Object.keys(knownAgents).join(',')}]`);
904
+ }
905
+ /**
906
+ * Deliver an a2a message to another agent. Same-machine peers (registered in
907
+ * AgentRegistry) receive it via HTTP POST to their `/a2a/inbox` endpoint —
908
+ * the canonical transport because Telegram structurally blocks bot-to-bot
909
+ * delivery. Cross-machine peers fall back to the Telegram bot path (preserved
910
+ * but currently unreachable due to the same block; tracked follow-up).
911
+ *
912
+ * Used by BOTH directions: the mentor's `deliverToMentee` (role=mentor) and
913
+ * the mentee's reply (role=mentor-reply). Returns true iff delivered.
914
+ */
915
+ async deliverA2aMessage(opts) {
783
916
  const ledger = this.getOrCreateA2aLedger();
784
- const sessionTimeoutMs = cfg.sessionTimeoutMs;
785
- const mentorMessageHandler = async (msg, _ctx) => {
786
- // 1. Spawn a mentee session and bounded-wait for completion. The
787
- // mentee's normal default-tool agent (not the Stage-A constrained
788
- // one) — the mentor is asking this agent to do real work.
789
- let reply = '';
790
- let sessionId = '';
791
- try {
792
- const session = await self.sessionManager.spawnSession({
793
- name: `mentee-handle-${Date.now()}`,
794
- prompt: msg.body,
795
- maxDurationMinutes: Math.max(1, Math.ceil(sessionTimeoutMs / 60_000)),
796
- });
797
- sessionId = session.id;
798
- const tmux = session.tmuxSession;
799
- const pollIntervalMs = 2_000;
800
- const pollIterations = Math.max(1, Math.ceil(sessionTimeoutMs / pollIntervalMs));
801
- let finished = false;
802
- for (let i = 0; i < pollIterations; i++) {
803
- const stillRunning = self.sessionManager
804
- .listRunningSessions()
805
- .some((s) => s.id === session.id);
806
- if (!stillRunning) {
807
- finished = true;
808
- break;
917
+ const id = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
918
+ // ── Same-machine: POST to peer's /a2a/inbox ──
919
+ try {
920
+ const { listAgents } = await import('../core/AgentRegistry.js');
921
+ const { getAgentToken } = await import('../messaging/AgentTokenManager.js');
922
+ const peers = listAgents();
923
+ const localPeer = peers.find((a) => a.name === opts.toAgent && a.name !== this.config.projectName);
924
+ if (localPeer?.port) {
925
+ const peerToken = getAgentToken(localPeer.name);
926
+ if (peerToken) {
927
+ const tsNow = Date.now();
928
+ const marker = `[a2a:from=${opts.fromAgent} to=${opts.toAgent} role=${opts.role} id=${id} corr=${opts.corr} ts=${tsNow} v=${A2A_VERSION}]`;
929
+ const fullText = `${marker}\n\n${opts.body}`;
930
+ const resp = await fetch(`http://localhost:${localPeer.port}/a2a/inbox`, {
931
+ method: 'POST',
932
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${peerToken}` },
933
+ body: JSON.stringify({
934
+ text: fullText,
935
+ topicId: opts.telegramTopicId ?? 0,
936
+ senderAgent: opts.fromAgent,
937
+ senderIsBot: true,
938
+ senderBotId: opts.toBotId ?? `${opts.fromAgent}-local`,
939
+ }),
940
+ signal: AbortSignal.timeout(10_000),
941
+ });
942
+ if (resp.ok) {
943
+ const result = (await resp.json().catch(() => ({})));
944
+ if (result.agentMessage === true) {
945
+ try {
946
+ ledger.appendSent({
947
+ ts: tsNow, from: opts.fromAgent, to: opts.toAgent, role: opts.role,
948
+ id, corr: opts.corr, result: 'sent', transport: 'a2a-inbox-local',
949
+ });
950
+ }
951
+ catch { /* best-effort */ }
952
+ console.log(`[a2a] delivered → ${opts.toAgent} via local /a2a/inbox (role=${opts.role}, corr=${opts.corr})`);
953
+ return true;
954
+ }
955
+ console.warn(`[a2a] local /a2a/inbox refused (to=${opts.toAgent}, role=${opts.role}, reason=${result.reason ?? 'unknown'})`);
809
956
  }
810
- await new Promise((r) => setTimeout(r, pollIntervalMs));
811
- }
812
- if (!finished) {
813
- try {
814
- self.sessionManager.killSession(session.id);
957
+ else {
958
+ console.warn(`[a2a] local /a2a/inbox HTTP ${resp.status} (to=${opts.toAgent}, role=${opts.role})`);
815
959
  }
816
- catch { /* best-effort */ }
817
- console.warn(`[mentee] session ${session.id} timed out at ${sessionTimeoutMs}ms (corr=${msg.corr}); skipping reply-out`);
818
- return;
819
960
  }
820
- reply = self.sessionManager.captureOutput(tmux, 200) ?? '';
821
- }
822
- catch (err) {
823
- console.warn(`[mentee] mentor-message handler spawn failed (corr=${msg.corr}, sessionId=${sessionId}):`, err instanceof Error ? err.message : String(err));
824
- return;
825
- }
826
- if (!reply.trim()) {
827
- console.warn(`[mentee] mentee session produced empty reply for corr=${msg.corr}; skipping reply-out`);
828
- return;
961
+ else {
962
+ console.warn(`[a2a] no token for local peer ${localPeer.name}; cannot deliver ${opts.role}`);
963
+ }
829
964
  }
830
- // 2. Reply OUT — orchestrator-level (NOT a capability passed to the
831
- // handler — see installMentorMessageHook docs above).
832
- const senderBotId = cfg.knownMentors[msg.from]?.botId ?? '';
965
+ }
966
+ catch (err) {
967
+ console.warn(`[a2a] local-inbox delivery attempt failed (to=${opts.toAgent}): ${err instanceof Error ? err.message : String(err)}`);
968
+ }
969
+ // ── Cross-machine Telegram fallback (mentor→mentee only; currently
970
+ // unreachable due to bot-to-bot block — tracked follow-up). ──
971
+ if (opts.telegramBot && opts.telegramTopicId !== undefined && opts.toBotId && opts.botToken) {
833
972
  try {
834
973
  const result = await sendAgentMessage({
835
- fromAgent: cfg.localAgentName,
836
- toAgent: msg.from,
837
- role: 'mentor-reply',
838
- toTopicId: cfg.replyTopicId,
839
- message: reply,
840
- id: `mr-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
841
- correlationId: msg.corr || msg.id,
974
+ fromAgent: opts.fromAgent, toAgent: opts.toAgent, role: opts.role,
975
+ toTopicId: opts.telegramTopicId, message: opts.body, id, correlationId: opts.corr,
842
976
  }, {
843
977
  send: async (topicId, text) => {
844
978
  try {
845
- const res = await adapter.sendToTopic(topicId, text);
979
+ const res = await opts.telegramBot.sendToTopic(topicId, text);
846
980
  return { ok: true, messageId: String(res.messageId) };
847
981
  }
848
982
  catch (e) {
@@ -851,32 +985,19 @@ export class AgentServer {
851
985
  },
852
986
  appendAudit: (row) => ledger.appendSent(row),
853
987
  now: () => Date.now(),
854
- mintId: () => `mr-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
855
- allowedRoles: new Set(['mentor-reply']),
856
- // Note: the primary adapter's token is not surfaced on the
857
- // adapter (intentionally — receiver doesn't need it). Scrubbing
858
- // is best-effort on the adapter-side; this caller is a no-op
859
- // for the botToken-scrub helper.
860
- botToken: undefined,
861
- fromBotId: '',
862
- toBotId: senderBotId,
988
+ mintId: () => id,
989
+ allowedRoles: opts.allowedRoles,
990
+ botToken: opts.botToken,
991
+ fromBotId: opts.botToken.split(':')[0],
992
+ toBotId: opts.toBotId,
863
993
  });
864
- if (!result.ok) {
865
- console.warn(`[mentee] mentor-reply send refused (corr=${msg.corr}): ${result.reason ?? 'unknown'}`);
866
- }
994
+ return result.ok;
867
995
  }
868
996
  catch (err) {
869
- console.warn(`[mentee] mentor-reply send failed (corr=${msg.corr}):`, err instanceof Error ? err.message : String(err));
997
+ console.warn(`[a2a] telegram fallback failed (to=${opts.toAgent}): ${err instanceof Error ? err.message : String(err)}`);
870
998
  }
871
- };
872
- const hook = buildAgentMessageHook({
873
- config: recipientCfg,
874
- ledger,
875
- processedIds: this.getOrCreateA2aProcessedIds(),
876
- roleHandlers: new Map([['mentor', mentorMessageHandler]]),
877
- });
878
- adapter.setAgentMessageHook(hook);
879
- console.log(`[mentee] receiver wiring installed — localAgent=${cfg.localAgentName}, knownMentors=[${Object.keys(cfg.knownMentors).join(',')}], reply→${cfg.replyChatId}/topic ${cfg.replyTopicId}`);
999
+ }
1000
+ return false;
880
1001
  }
881
1002
  /** Snapshot of mentee config for the receiver-wiring installer. */
882
1003
  getMenteeConfigSnapshot() {
@@ -1031,125 +1152,32 @@ export class AgentServer {
1031
1152
  console.warn(`[mentor] deliverToMentee deferred — prior-prompt-in-flight (corr=${check.outstandingCorr}, sentAt=${check.sentAt})`);
1032
1153
  return;
1033
1154
  }
1034
- // Same-machine HTTP transport (post-bot-to-bot-block fix). Try
1035
- // delivering to the peer's `/a2a/inbox` endpoint when the mentee
1036
- // is registered as a local peer. Telegram structurally blocks
1037
- // bot-to-bot delivery, so the bot path can't reach a same-machine
1038
- // peer's primary adapter; the inbox route is the canonical local
1039
- // transport. If no local peer is found we fall through to the
1040
- // Telegram bot path (preserved for the cross-machine case;
1041
- // currently doesn't deliver due to the same block, tracked as a
1042
- // follow-up).
1043
- let deliveredLocally = false;
1044
- try {
1045
- const { listAgents } = await import('../core/AgentRegistry.js');
1046
- const { getAgentToken } = await import('../messaging/AgentTokenManager.js');
1047
- const peers = listAgents();
1048
- const localPeer = peers.find((a) => a.name === menteeAgent && a.name !== self.config.projectName);
1049
- if (localPeer?.port) {
1050
- const peerToken = getAgentToken(localPeer.name);
1051
- if (peerToken) {
1052
- const tsNow = Date.now();
1053
- const marker = `[a2a:from=echo to=${menteeAgent} role=mentor id=${corr} corr=${corr} ts=${tsNow} v=1]`;
1054
- const fullText = `${marker}\n${message}`;
1055
- const resp = await fetch(`http://localhost:${localPeer.port}/a2a/inbox`, {
1056
- method: 'POST',
1057
- headers: {
1058
- 'Content-Type': 'application/json',
1059
- 'Authorization': `Bearer ${peerToken}`,
1060
- },
1061
- body: JSON.stringify({
1062
- text: fullText,
1063
- topicId: cfg.menteeTopicId ?? 0,
1064
- senderAgent: 'echo',
1065
- senderIsBot: true,
1066
- senderBotId: cfg.botToken ? cfg.botToken.split(':')[0] : 'echo-local',
1067
- }),
1068
- signal: AbortSignal.timeout(10_000),
1069
- });
1070
- if (resp.ok) {
1071
- const result = (await resp.json().catch(() => ({})));
1072
- if (result.agentMessage === true) {
1073
- outstanding.markSent(corr, menteeAgent);
1074
- try {
1075
- const ledger = self.getOrCreateA2aLedger();
1076
- ledger.appendSent({
1077
- ts: tsNow,
1078
- from: 'echo',
1079
- to: menteeAgent,
1080
- role: 'mentor',
1081
- id: corr,
1082
- corr,
1083
- result: 'sent',
1084
- transport: 'a2a-inbox-local',
1085
- });
1086
- }
1087
- catch { /* best-effort */ }
1088
- console.log(`[mentor] deliverToMentee → local a2a inbox (peer=${menteeAgent}:${localPeer.port}, corr=${corr})`);
1089
- deliveredLocally = true;
1090
- }
1091
- else {
1092
- console.warn(`[mentor] deliverToMentee local-inbox refused (reason=${result.reason ?? 'unknown'}, peer=${menteeAgent}); falling back to Telegram bot transport`);
1093
- }
1094
- }
1095
- else {
1096
- console.warn(`[mentor] deliverToMentee local-inbox HTTP ${resp.status} (peer=${menteeAgent}); falling back to Telegram bot transport`);
1097
- }
1098
- }
1099
- else {
1100
- console.warn(`[mentor] deliverToMentee local-inbox skipped — no token for peer ${localPeer.name}; falling back to Telegram bot transport`);
1101
- }
1102
- }
1103
- }
1104
- catch (err) {
1105
- console.warn(`[mentor] deliverToMentee local-inbox attempt failed (non-fatal, falling back to Telegram): ${err instanceof Error ? err.message : String(err)}`);
1106
- }
1107
- if (deliveredLocally)
1108
- return;
1109
- // Telegram bot fallback — preserved for cross-machine peers (not
1110
- // currently deliverable due to Telegram bot-to-bot block; tracked).
1111
- if (!cfg.botToken || !cfg.menteeBotId || !cfg.menteeChatId || cfg.menteeTopicId === undefined) {
1112
- console.warn(`[mentor] deliverToMentee skipped — mentor-bot config incomplete (need mentor.botToken + menteeBotId + menteeChatId + menteeTopicId; framework=${framework})`);
1113
- return;
1114
- }
1115
- const bot = self.getOrCreateMentorBot(cfg.botToken, cfg.menteeChatId);
1116
- if (!bot)
1117
- return;
1118
- const ledger = self.getOrCreateA2aLedger();
1119
- try {
1120
- const result = await sendAgentMessage({
1121
- fromAgent: 'echo',
1122
- toAgent: menteeAgent,
1123
- role: 'mentor',
1124
- toTopicId: cfg.menteeTopicId,
1125
- message,
1126
- id: corr,
1127
- correlationId: corr,
1128
- }, {
1129
- send: async (topicId, text) => {
1130
- try {
1131
- const id = await bot.sendToTopic(topicId, text);
1132
- return { ok: true, messageId: String(id ?? '') };
1133
- }
1134
- catch (err) {
1135
- return { ok: false, error: err };
1136
- }
1137
- },
1138
- appendAudit: (row) => ledger.appendSent(row),
1139
- now: () => Date.now(),
1140
- mintId: () => corr,
1141
- allowedRoles: new Set(['mentor']),
1142
- botToken: cfg.botToken,
1143
- fromBotId: cfg.botToken.split(':')[0],
1144
- toBotId: cfg.menteeBotId,
1145
- });
1146
- if (result.ok) {
1147
- // Mark only on successful send — a failed send doesn't owe a reply.
1148
- outstanding.markSent(corr, menteeAgent);
1149
- }
1155
+ // Deliver via the unified a2a transport: same-machine /a2a/inbox
1156
+ // when the mentee is a local peer (Telegram blocks bot-to-bot, so
1157
+ // this is the canonical path), else the Telegram bot fallback
1158
+ // (cross-machine; currently unreachable, tracked follow-up).
1159
+ const telegramBot = cfg.botToken && cfg.menteeChatId
1160
+ ? self.getOrCreateMentorBot(cfg.botToken, cfg.menteeChatId) ?? undefined
1161
+ : undefined;
1162
+ const delivered = await self.deliverA2aMessage({
1163
+ fromAgent: 'echo',
1164
+ toAgent: menteeAgent,
1165
+ role: 'mentor',
1166
+ corr,
1167
+ body: message,
1168
+ allowedRoles: new Set(['mentor']),
1169
+ telegramTopicId: cfg.menteeTopicId,
1170
+ toBotId: cfg.menteeBotId,
1171
+ telegramBot: telegramBot
1172
+ ? { sendToTopic: (t, txt) => telegramBot.sendToTopic(t, txt) }
1173
+ : undefined,
1174
+ botToken: cfg.botToken,
1175
+ });
1176
+ if (delivered) {
1177
+ outstanding.markSent(corr, menteeAgent);
1150
1178
  }
1151
- catch (err) {
1152
- console.warn('[mentor] deliverToMentee send failed (non-fatal):', err instanceof Error ? err.message : String(err));
1179
+ else {
1180
+ console.warn(`[mentor] deliverToMentee did not deliver (corr=${corr}, mentee=${menteeAgent}) — no local peer + telegram fallback unavailable/blocked`);
1153
1181
  }
1154
1182
  },
1155
1183
  onTickRan: () => {