agent-nuvira 3.1.1 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/cli/chat.d.ts.map +1 -1
  2. package/dist/cli/chat.js +17 -2
  3. package/dist/cli/chat.js.map +1 -1
  4. package/dist/cli/nlu.d.ts +8 -0
  5. package/dist/cli/nlu.d.ts.map +1 -1
  6. package/dist/cli/nlu.js +57 -0
  7. package/dist/cli/nlu.js.map +1 -1
  8. package/dist/gateway/gateway-log.d.ts +1 -1
  9. package/dist/gateway/gateway-log.d.ts.map +1 -1
  10. package/dist/gateway/gateway-log.js.map +1 -1
  11. package/dist/gateway/registry.d.ts +85 -1
  12. package/dist/gateway/registry.d.ts.map +1 -1
  13. package/dist/gateway/registry.js +438 -45
  14. package/dist/gateway/registry.js.map +1 -1
  15. package/dist/inference/tool-call-utils.d.ts.map +1 -1
  16. package/dist/inference/tool-call-utils.js +70 -1
  17. package/dist/inference/tool-call-utils.js.map +1 -1
  18. package/dist/learning/deferred-task.d.ts +159 -0
  19. package/dist/learning/deferred-task.d.ts.map +1 -0
  20. package/dist/learning/deferred-task.js +451 -0
  21. package/dist/learning/deferred-task.js.map +1 -0
  22. package/dist/learning/model-first-router.d.ts.map +1 -1
  23. package/dist/learning/model-first-router.js +8 -0
  24. package/dist/learning/model-first-router.js.map +1 -1
  25. package/dist/learning/model-registry.d.ts +30 -0
  26. package/dist/learning/model-registry.d.ts.map +1 -1
  27. package/dist/learning/model-registry.js +61 -0
  28. package/dist/learning/model-registry.js.map +1 -1
  29. package/dist/learning/resilient-call.d.ts.map +1 -1
  30. package/dist/learning/resilient-call.js +38 -3
  31. package/dist/learning/resilient-call.js.map +1 -1
  32. package/dist/nlu/conversation-gate.d.ts +22 -0
  33. package/dist/nlu/conversation-gate.d.ts.map +1 -1
  34. package/dist/nlu/conversation-gate.js +29 -6
  35. package/dist/nlu/conversation-gate.js.map +1 -1
  36. package/dist/nlu/intent-confirm.d.ts +82 -0
  37. package/dist/nlu/intent-confirm.d.ts.map +1 -0
  38. package/dist/nlu/intent-confirm.js +146 -0
  39. package/dist/nlu/intent-confirm.js.map +1 -0
  40. package/dist/nlu/learnings.d.ts +101 -0
  41. package/dist/nlu/learnings.d.ts.map +1 -0
  42. package/dist/nlu/learnings.js +283 -0
  43. package/dist/nlu/learnings.js.map +1 -0
  44. package/dist/tools/tool-loop.d.ts.map +1 -1
  45. package/dist/tools/tool-loop.js +16 -2
  46. package/dist/tools/tool-loop.js.map +1 -1
  47. package/dist/web-dashboard/chat-console.d.ts +29 -0
  48. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  49. package/dist/web-dashboard/chat-console.js +97 -18
  50. package/dist/web-dashboard/chat-console.js.map +1 -1
  51. package/dist/web-dashboard/chat-retry.d.ts +143 -0
  52. package/dist/web-dashboard/chat-retry.d.ts.map +1 -0
  53. package/dist/web-dashboard/chat-retry.js +219 -0
  54. package/dist/web-dashboard/chat-retry.js.map +1 -0
  55. package/dist/web-dashboard/server.d.ts +17 -0
  56. package/dist/web-dashboard/server.d.ts.map +1 -1
  57. package/dist/web-dashboard/server.js +120 -1
  58. package/dist/web-dashboard/server.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/web-dashboard/public/assets/{index-CQW0qv1r.js → index-Co7Hk2FT.js} +27 -27
  61. package/src/web-dashboard/public/assets/index-Co7Hk2FT.js.map +1 -0
  62. package/src/web-dashboard/public/index.html +1 -1
  63. package/src/web-dashboard/public/assets/index-CQW0qv1r.js.map +0 -1
@@ -32,13 +32,26 @@ import { logGatewayEvent, previewText } from './gateway-log.js';
32
32
  import { hasCodingAction, looksLikeAgentCliAsk, resolveAskKind } from '../nlu/conversation-gate.js';
33
33
  import { GatewayChatStore } from './chat-store.js';
34
34
  import { looksLikeConfusedScaffoldingReply, looksLikeReasoningLeakReply, stripLeadingReasoningTrace, stripToolCallArtifacts, stripReasoningLeak, toUserFacingGenerationError, } from '../inference/tool-call-utils.js';
35
- import { markFailoverAttempts, modelBreadthReport, renderModelBreadthReport, } from '../learning/resilient-call.js';
35
+ import { createResilientCallLLM, markFailoverAttempts, modelBreadthReport, renderModelBreadthReport, } from '../learning/resilient-call.js';
36
+ // The intent audit: on a REPEATED failure, ask the model what the ask really
37
+ // needs and feed a confirmed correction back into the NLU (see learnings.ts).
38
+ import { confirmRoutedIntent, intentConfirmedNote, intentCorrectedNote, } from '../nlu/intent-confirm.js';
39
+ // The retry queue behind the "Reply *yes* and I will keep trying" offer. LEAF
40
+ // import: a plain store + matcher, so the gateway does not pull routing in for it.
41
+ import { abandonedLine, acceptedLine, cancelTasksFor, confirmTask, deferTask, dueTasks, expiredTasks, getPendingTask, isRetryAcceptance, isRetryDecline, removeDeferredTask, retryingLine, updateDeferredTask, } from '../learning/deferred-task.js';
36
42
  import { logger } from '../utils/logger.js';
37
43
  import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
38
44
  import { join } from 'node:path';
39
45
  import { resolveBuffConfigDir } from '../config/paths.js';
40
46
  /** How often the running gateway drains due delivery entries (ms). */
41
47
  const DELIVERY_DRAIN_INTERVAL_MS = 30_000;
48
+ /**
49
+ * How often the running gateway checks for deferred asks whose wait is over.
50
+ * 15s: a retry is already gated by the model's own free-up ETA, so this only
51
+ * bounds how late the reply can be — tight enough to feel immediate, loose
52
+ * enough to cost nothing on a quiet gateway.
53
+ */
54
+ const RETRY_DRAIN_INTERVAL_MS = 15_000;
42
55
  /** Cap the per-target send-failure map (diagnostics only — never grows). */
43
56
  const MAX_TRACKED_SEND_ERRORS = 200;
44
57
  // ─── Response cleanup ──────────────────────────────────────────────────────
@@ -485,6 +498,14 @@ export class GatewayRegistry {
485
498
  * double-count would prematurely fail entries). */
486
499
  drainChain = Promise.resolve();
487
500
  started = false;
501
+ /**
502
+ * The deferred-retry drain. A failed turn is queued (see `deferFailedTask`)
503
+ * and re-run here when a model is expected back — the mechanism behind the
504
+ * "Reply *yes*" offer, which used to be a promise with nothing behind it.
505
+ */
506
+ retryTimer = null;
507
+ /** Serializes retry runs so a long one is never started twice. */
508
+ retryChain = Promise.resolve();
488
509
  /** Epoch ms of start() — the uptime reported in the heartbeat. */
489
510
  startedAt = 0;
490
511
  /** Beats written this run (monotonic; a stalled count means a stalled loop). */
@@ -743,7 +764,7 @@ export class GatewayRegistry {
743
764
  * channel (a second message while a run is active queues behind it).
744
765
  * Every message is recorded in the inbox (P2) with its disposition.
745
766
  */
746
- async handleInbound(msg) {
767
+ async handleInbound(msg, opts = {}) {
747
768
  // Auto-learn Telegram chat IDs: when a message arrives from a Telegram
748
769
  // user, update any contact/alias that used a phone number format.
749
770
  // Also auto-registers new users with status: 'pending' for admin approval.
@@ -876,7 +897,11 @@ export class GatewayRegistry {
876
897
  // must never break handling).
877
898
  const historyKey = `${msg.platform}:${msg.channelId}`;
878
899
  try {
879
- this.chatStore.recordInbound(historyKey, msg.text);
900
+ // A deferred RETRY or an audit RE-ROUTE must not re-record the ask: the
901
+ // original arrival already wrote that user turn, and a second copy would
902
+ // make the model see the same request twice in the thread.
903
+ if (!opts.retryOf && !opts.reentry)
904
+ this.chatStore.recordInbound(historyKey, msg.text);
880
905
  }
881
906
  catch {
882
907
  /* best-effort */
@@ -895,6 +920,19 @@ export class GatewayRegistry {
895
920
  record('clarified', pendingAnswerLine);
896
921
  return pendingAnswerLine;
897
922
  }
923
+ // DEFERRED RETRY ANSWER — an earlier failure told the sender "Reply *yes*
924
+ // and I will keep trying", so a pending task may be waiting for exactly
925
+ // this message. Placed AFTER the pending-question gate (a turn ACTIVELY
926
+ // holding for an answer wins — it is mid-run, the queue is not) and BEFORE
927
+ // every routing decision, because the reply belongs to the OFFER rather
928
+ // than to the NLU. A message that is neither yes nor no falls through
929
+ // unchanged, so a real new request is never swallowed.
930
+ const retryLine = this.consumeRetryAnswer(msg);
931
+ if (retryLine) {
932
+ await replyTo(retryLine);
933
+ record('clarified', retryLine);
934
+ return retryLine;
935
+ }
898
936
  // ── Local-CLI asks ── "run nuvira gateway status" names a command for the
899
937
  // OPERATOR's terminal. Observed live: it was dispatched to the multi-agent
900
938
  // pipeline as a create intent, burned 112s, failed, and wrote an approval
@@ -937,8 +975,8 @@ export class GatewayRegistry {
937
975
  // `parsed.action.run`, so the two surfaces disagreed on the same ask —
938
976
  // "how do I add JWT auth to the app?" got prose here while chat/execute
939
977
  // did the work, and a question phrased like a task still burned a run.
940
- const askKind = resolveAskKind(msg.text, parsed);
941
- logger.debug(`gateway: route ${askKind} (intent ${parsed.intent} @ ${parsed.confidence.toFixed(2)}, coding=${hasCodingAction(msg.text)})`);
978
+ const askKind = opts.forceKind ?? resolveAskKind(msg.text, parsed);
979
+ logger.debug(`gateway: route ${askKind}${opts.forceKind ? ' (audit override)' : ''} (intent ${parsed.intent} @ ${parsed.confidence.toFixed(2)}, coding=${hasCodingAction(msg.text)})`);
942
980
  // Chat ask: a REAL chat answer through the same engine as the dashboard
943
981
  // console (ChatCommand.answerOnce) — so "write a poem and send it to Alex"
944
982
  // on WhatsApp actually writes the poem, and the model's toolset includes
@@ -951,6 +989,10 @@ export class GatewayRegistry {
951
989
  if (answer && answer.content.trim() && !answer.generationFailed) {
952
990
  await replyTo(answer.content);
953
991
  record('chat', answer.content);
992
+ // A retry that produced a real answer is DONE — the queue entry must go,
993
+ // or the drain would keep re-running a fulfilled ask.
994
+ if (opts.retryOf)
995
+ removeDeferredTask(opts.retryOf);
954
996
  return answer.content;
955
997
  }
956
998
  // Generation failed. The sender gets NO parsed intent/confidence (that
@@ -968,11 +1010,25 @@ export class GatewayRegistry {
968
1010
  // A failure the sender cannot act on is a failure twice over. When a
969
1011
  // model IS configured, say which models were tried, which are parked and
970
1012
  // why, and offer to keep checking — instead of "couldn't get an answer".
1013
+ // BEFORE reporting the failure: ask the model whether a WRITTEN ANSWER is
1014
+ // even the right route (see `auditAndReroute`). A chat ask that keeps
1015
+ // failing is often a coding task the NLU misread — in which case the
1016
+ // honest answer is to do the work, not to report a model problem.
1017
+ const audit = await this.auditAndReroute(msg, 'chat', opts);
1018
+ if (audit?.rerouted) {
1019
+ // The corrected route already replied through its own branch.
1020
+ return audit.reply;
1021
+ }
971
1022
  const line = this.generationFailureLine();
972
- const detail = this.hasConfiguredModel()
973
- ? renderModelBreadthReport(modelBreadthReport(attemptMark, this.configManager), { task: msg.text })
1023
+ const breadth = this.hasConfiguredModel()
1024
+ ? modelBreadthReport(attemptMark, this.configManager)
974
1025
  : undefined;
975
- const full = detail ? `${line}\n\n${detail}` : line;
1026
+ const detail = breadth ? renderModelBreadthReport(breadth, { task: msg.text }) : undefined;
1027
+ // The offer above is only honest if something ENFORCES it: queue the ask
1028
+ // and retry it when a model is back (see drainDeferredTasks).
1029
+ if (detail)
1030
+ this.deferFailedAsk(msg, 'chat', breadth?.nextFreeInMs, line);
1031
+ const full = [line, detail, audit?.note].filter(Boolean).join('\n\n');
976
1032
  await replyTo(full);
977
1033
  record('chat', full);
978
1034
  return full;
@@ -985,19 +1041,25 @@ export class GatewayRegistry {
985
1041
  // task with no delivery ask stays on the fast direct-orchestrator path.
986
1042
  if (hasDeliveryAsk(msg.text)) {
987
1043
  const answer = await this.runInboundChat(msg);
988
- const line = answer && answer.content.trim() && !answer.generationFailed
989
- ? answer.content
990
- : this.generationFailureLine();
1044
+ const answered = !!answer && answer.content.trim().length > 0 && !answer.generationFailed;
1045
+ const line = answered ? answer.content : this.generationFailureLine();
991
1046
  await replyTo(line);
992
1047
  record('pipeline', line);
1048
+ if (answered && opts.retryOf)
1049
+ removeDeferredTask(opts.retryOf);
993
1050
  // Status recipients: a pipeline task (even one routed through the loop
994
1051
  // for delivery) forwards its outcome to the configured contacts.
995
1052
  await this.notifyStatusRecipients(line);
996
1053
  return line;
997
1054
  }
998
- // Pure pipeline intent — run serialized. Deliberately does NOT touch
1055
+ // Pure pipeline intent — the RUN is serialized. Deliberately does NOT touch
999
1056
  // activeChannel before the gate: a chat message arriving while a pipeline
1000
1057
  // run is in flight must not redirect the run's board events.
1058
+ //
1059
+ // Only the WORK sits inside the chain. Composing and sending the reply used
1060
+ // to live there too, which is why a failed run could not be re-examined:
1061
+ // the intent audit re-enters `handleInbound`, and doing that from inside the
1062
+ // chain would wait on the chain it is itself blocking (a deadlock).
1001
1063
  await replyTo(`✅ Got it — running the ${parsed.action.name} pipeline…`);
1002
1064
  const pipelineAttemptMark = markFailoverAttempts();
1003
1065
  const run = this.runChain.then(async () => {
@@ -1006,47 +1068,178 @@ export class GatewayRegistry {
1006
1068
  // P2 — origin context: the pipeline model knows who it's talking to and
1007
1069
  // where (so it can reply/forward to the right place via gateway_send).
1008
1070
  const origin = `${PLATFORM_LABELS[msg.platform]} ${msg.isGroup ? 'group' : 'chat'} ${msg.from ?? msg.senderId ?? msg.channelId}`;
1009
- const result = await runPipelineTool(msg.text, this.configManager, {
1071
+ return runPipelineTool(msg.text, this.configManager, {
1010
1072
  board: false,
1011
1073
  mode: parsed.mode,
1012
1074
  taskIntentHint: parsed.action.taskIntent,
1013
1075
  origin,
1014
1076
  });
1015
- const reply = composePipelineReply({
1016
- success: result.success,
1017
- summary: result.summary,
1018
- structured: result.result
1019
- ? {
1020
- tasksCompleted: result.result.tasksCompleted,
1021
- tasksTotal: result.result.tasksTotal,
1022
- agentResults: result.result.agentResults,
1023
- }
1024
- : null,
1077
+ });
1078
+ this.runChain = run.catch(() => undefined);
1079
+ const result = await run;
1080
+ const reply = composePipelineReply({
1081
+ success: result.success,
1082
+ summary: result.summary,
1083
+ structured: result.result
1084
+ ? {
1085
+ tasksCompleted: result.result.tasksCompleted,
1086
+ tasksTotal: result.result.tasksTotal,
1087
+ agentResults: result.result.agentResults,
1088
+ }
1089
+ : null,
1090
+ });
1091
+ // A FAILED pipeline is where the routing itself deserves one audit: six
1092
+ // agent steps were just spent on something the rules may have misread, and
1093
+ // the sender's alternative is to rephrase and hope. When the confirmation
1094
+ // says the ask was NOT a coding task, the right move is to answer it rather
1095
+ // than to report the failure — so the failure report is deliberately not
1096
+ // sent at all in that path (one reply, not two).
1097
+ let intentNote;
1098
+ if (!result.success) {
1099
+ const audit = await this.auditAndReroute(msg, 'pipeline', opts);
1100
+ if (audit?.rerouted) {
1101
+ logGatewayEvent('pipeline.completed', {
1102
+ platform: msg.platform,
1103
+ channelId: msg.channelId,
1104
+ success: false,
1105
+ summary: result.summary,
1106
+ tools: parsed.action.name,
1107
+ reroutedTo: 'chat',
1108
+ }, 'warn');
1109
+ // Status recipients still hear the outcome — the ask was answered, just
1110
+ // by the other route.
1111
+ await this.notifyStatusRecipients(audit.reply);
1112
+ return audit.reply;
1113
+ }
1114
+ intentNote = audit?.note;
1115
+ }
1116
+ // On failure, name the models that were tried and the ones parked, so the
1117
+ // sender learns WHY rather than just that it failed.
1118
+ const report = result.success
1119
+ ? undefined
1120
+ : modelBreadthReport(pipelineAttemptMark, this.configManager);
1121
+ const breadth = report ? renderModelBreadthReport(report, { task: msg.text }) : undefined;
1122
+ // Queue the ask so the retry offer in that report is actually enforced.
1123
+ if (breadth)
1124
+ this.deferFailedAsk(msg, 'pipeline', report?.nextFreeInMs, reply);
1125
+ const finalReply = [reply, breadth, intentNote].filter(Boolean).join('\n\n');
1126
+ await replyTo(finalReply);
1127
+ // Only a SUCCESSFUL run settles the retry: on failure the branch above has
1128
+ // already re-queued this same task with a fresh wait.
1129
+ if (result.success && opts.retryOf)
1130
+ removeDeferredTask(opts.retryOf);
1131
+ logGatewayEvent('pipeline.completed', {
1132
+ platform: msg.platform,
1133
+ channelId: msg.channelId,
1134
+ success: result.success,
1135
+ summary: result.summary,
1136
+ tools: parsed.action.name,
1137
+ }, result.success ? 'info' : 'warn');
1138
+ record('pipeline', finalReply);
1139
+ // Status recipients: ALWAYS forward the completion summary to the
1140
+ // configured contacts/groups, whoever triggered it.
1141
+ await this.notifyStatusRecipients(finalReply);
1142
+ return finalReply;
1143
+ }
1144
+ /**
1145
+ * On a REPEATED failure, ask the model what this ask really needs, and act on
1146
+ * the answer.
1147
+ *
1148
+ * THE PROBLEM IT SOLVES. A failure says nothing about whether the request was
1149
+ * read correctly — but a failure that happens TWICE (a queued retry, an audit
1150
+ * re-route, or a spent six-step pipeline) is expensive enough that the routing
1151
+ * itself is worth one question. Without it, a misreading is permanent: the same
1152
+ * ask runs down the same wrong path forever, and the user's only recourse is to
1153
+ * rephrase. This is the self-correction half of routing; `learnings.ts` is the
1154
+ * memory half, so the NEXT identical ask routes right the first time.
1155
+ *
1156
+ * Runs at most ONCE per inbound message (the `audited` flag), which is what
1157
+ * stops a misreading ping-ponging between the two routes.
1158
+ *
1159
+ * @returns null when no audit ran; `{rerouted: true}` when the corrected route
1160
+ * was run (its reply already delivered); `{rerouted: false, note}` when the
1161
+ * model CONFIRMED the reading, so the caller keeps its failure report and
1162
+ * adds the confirmation line.
1163
+ */
1164
+ async auditAndReroute(msg, routed, opts) {
1165
+ if (opts.audited || opts.reentry || opts.forceKind)
1166
+ return null;
1167
+ // REPEATED failure only. A single failure mostly describes the world, and
1168
+ // auditing every one would spend a quota-limited model to second-guess a
1169
+ // decision that was usually right.
1170
+ const queued = getPendingTask(msg.platform, msg.channelId);
1171
+ const repeated = !!opts.retryOf || (queued?.attempts ?? 0) >= 1;
1172
+ if (!repeated)
1173
+ return null;
1174
+ if (!this.hasConfiguredModel())
1175
+ return null;
1176
+ let verdict;
1177
+ try {
1178
+ const callLLM = createResilientCallLLM(this.configManager, {
1179
+ // A throwaway task identity on purpose: this probe must not inherit the
1180
+ // failover state of the turn that just died, and it must not write
1181
+ // cross-pipeline exclusions from a question about intent.
1182
+ task: { agentType: 'chat', description: msg.text },
1183
+ crossPipelineMemory: false,
1025
1184
  });
1026
- // On failure, name the models that were tried and the ones parked, so the
1027
- // sender learns WHY rather than just that it failed.
1028
- const breadth = result.success
1029
- ? undefined
1030
- : renderModelBreadthReport(modelBreadthReport(pipelineAttemptMark, this.configManager), {
1031
- task: msg.text,
1032
- });
1033
- const finalReply = breadth ? `${reply}\n\n${breadth}` : reply;
1034
- await replyTo(finalReply);
1035
- logGatewayEvent('pipeline.completed', {
1185
+ verdict = await confirmRoutedIntent({
1186
+ ask: msg.text,
1187
+ routed,
1188
+ callLLM: (prompt) => callLLM(prompt, { maxTokens: 160, temperature: 0 }),
1189
+ });
1190
+ }
1191
+ catch {
1192
+ return null;
1193
+ }
1194
+ if (verdict.agreed) {
1195
+ logGatewayEvent('intent.confirmed', {
1036
1196
  platform: msg.platform,
1037
1197
  channelId: msg.channelId,
1038
- success: result.success,
1039
- summary: result.summary,
1040
- tools: parsed.action.name,
1041
- }, result.success ? 'info' : 'warn');
1042
- record('pipeline', finalReply);
1043
- // Status recipients: ALWAYS forward the completion summary to the
1044
- // configured contacts/groups, whoever triggered it.
1045
- await this.notifyStatusRecipients(finalReply);
1046
- return finalReply;
1047
- });
1048
- this.runChain = run.catch(() => undefined);
1049
- return run;
1198
+ routed,
1199
+ probed: !verdict.failed,
1200
+ reason: verdict.reason,
1201
+ }, 'info');
1202
+ // The audit ran and confirmed the reading: say so, because "it failed" and
1203
+ // "it failed and I checked that I understood you" are different promises.
1204
+ return { rerouted: false, ...(verdict.failed ? {} : { note: intentConfirmedNote(routed) }) };
1205
+ }
1206
+ logger.warn(`gateway: intent audit corrected ${routed} → ${verdict.kind} for "${msg.text.slice(0, 70)}"${verdict.reason ? ` (${verdict.reason})` : ''}`);
1207
+ logGatewayEvent('intent.corrected', {
1208
+ platform: msg.platform,
1209
+ channelId: msg.channelId,
1210
+ from: routed,
1211
+ to: verdict.kind,
1212
+ reason: verdict.reason,
1213
+ learningId: verdict.learning?.id,
1214
+ }, 'warn');
1215
+ const corrected = verdict.kind;
1216
+ const line = intentCorrectedNote(corrected, verdict.reason);
1217
+ try {
1218
+ await this.sendToRef({ platform: msg.platform, channelId: msg.channelId }, line);
1219
+ }
1220
+ catch {
1221
+ /* best-effort — the corrected route still runs */
1222
+ }
1223
+ try {
1224
+ // Re-enter through the SAME entry point: the corrected route gets the
1225
+ // same gates, the same reply path and its own ledger rows, so it cannot
1226
+ // drift from a first-time ask. Fresh message id, `reentry` (the ask is
1227
+ // already in history), `audited` (never ping-pong the audit).
1228
+ const reply = await this.handleInbound({ ...msg, messageId: `${msg.messageId ?? 'ask'}-audit-${corrected}` }, {
1229
+ reentry: true,
1230
+ audited: true,
1231
+ forceKind: corrected,
1232
+ // Carry the retry identity through: the corrected route IS this
1233
+ // task's attempt, so its success must settle the queue entry rather
1234
+ // than leave a fulfilled ask waiting to be retried again.
1235
+ ...(opts.retryOf ? { retryOf: opts.retryOf } : {}),
1236
+ });
1237
+ return { rerouted: true, reply, to: corrected };
1238
+ }
1239
+ catch (err) {
1240
+ logger.warn(`gateway: audit re-route to ${corrected} failed: ${err instanceof Error ? err.message : String(err)}`);
1241
+ return { rerouted: false, note: line };
1242
+ }
1050
1243
  }
1051
1244
  /**
1052
1245
  * Chat-intent answer (write/explain/ask): run ONE tool-loop turn through the
@@ -1401,6 +1594,13 @@ export class GatewayRegistry {
1401
1594
  this.deliveryTimer = setInterval(() => {
1402
1595
  void this.drainDelivery().catch(() => undefined);
1403
1596
  }, DELIVERY_DRAIN_INTERVAL_MS);
1597
+ // Deferred retries: the queue is PERSISTED, so this also resumes asks that
1598
+ // were still waiting when the gateway restarted — the normal case for a
1599
+ // quota wait, which routinely outlives the process that offered it.
1600
+ this.retryTimer = setInterval(() => {
1601
+ void this.drainDeferredTasks().catch(() => undefined);
1602
+ }, RETRY_DRAIN_INTERVAL_MS);
1603
+ void this.drainDeferredTasks().catch(() => undefined);
1404
1604
  }
1405
1605
  /**
1406
1606
  * Auto-learn Telegram chat IDs: when a message arrives from a Telegram user,
@@ -1666,6 +1866,192 @@ export class GatewayRegistry {
1666
1866
  entry.resolve(match);
1667
1867
  return `👍 Got it — using "${match.answer}".`;
1668
1868
  }
1869
+ // ─── Deferred retries (the "Reply *yes*" offer, enforced) ────────────────
1870
+ /**
1871
+ * Resolve a reply against a pending retry offer. Returns a user-facing line
1872
+ * when the message WAS an answer to that offer, or `null` to let it fall
1873
+ * through to normal handling.
1874
+ *
1875
+ * Failing OPEN is the invariant (same as the pending-question machinery): a
1876
+ * message that is not clearly yes/no is never swallowed, and the queued task
1877
+ * simply keeps its place — so the sender can still accept or cancel later.
1878
+ */
1879
+ consumeRetryAnswer(msg) {
1880
+ const task = getPendingTask(msg.platform, msg.channelId);
1881
+ if (!task)
1882
+ return null;
1883
+ if (isRetryDecline(msg.text)) {
1884
+ const cancelled = cancelTasksFor(msg.platform, msg.channelId);
1885
+ logger.info(`gateway: sender declined the retry offer — ${cancelled} queued task(s) cancelled`);
1886
+ logGatewayEvent('retry.cancelled', {
1887
+ platform: msg.platform,
1888
+ channelId: msg.channelId,
1889
+ tasks: cancelled,
1890
+ }, 'info');
1891
+ return "👍 Okay — I've stopped retrying that. Ask me anything else whenever you're ready.";
1892
+ }
1893
+ if (!isRetryAcceptance(msg.text))
1894
+ return null;
1895
+ // The task was queued the moment the turn failed, so "yes" CONFIRMS it
1896
+ // rather than creating a second one — and confirmation is what buys the
1897
+ // long horizon (see `confirmTask`).
1898
+ const confirmed = confirmTask(task.id) ?? task;
1899
+ logger.info(`gateway: retry offer accepted (attempt ${confirmed.attempts}, next in ${Math.max(0, confirmed.notBefore - Date.now())}ms)`);
1900
+ logGatewayEvent('retry.accepted', {
1901
+ platform: msg.platform,
1902
+ channelId: msg.channelId,
1903
+ taskId: confirmed.id,
1904
+ attempts: confirmed.attempts,
1905
+ }, 'info');
1906
+ return acceptedLine(confirmed);
1907
+ }
1908
+ /**
1909
+ * Queue a failed ask for retry. Called from BOTH failure branches (chat and
1910
+ * pipeline) at the moment the breadth report — and therefore the "Reply
1911
+ * *yes*" offer — is being sent to the sender.
1912
+ *
1913
+ * Never throws and never awaits: a queue write must not delay or break the
1914
+ * failure reply the sender is owed.
1915
+ */
1916
+ deferFailedAsk(msg, kind, nextFreeInMs, lastError) {
1917
+ try {
1918
+ const { task, created } = deferTask({
1919
+ platform: msg.platform,
1920
+ channelId: msg.channelId,
1921
+ text: msg.text,
1922
+ kind,
1923
+ from: msg.from,
1924
+ senderId: msg.senderId,
1925
+ isGroup: msg.isGroup,
1926
+ nextFreeInMs,
1927
+ lastError,
1928
+ });
1929
+ logger.info(`gateway: ${created ? 'queued' : 're-queued'} deferred ask (${kind}) for ${msg.platform}:${msg.channelId} — next attempt in ${Math.max(0, task.notBefore - Date.now())}ms`);
1930
+ }
1931
+ catch (err) {
1932
+ logger.warn(`gateway: could not queue the failed ask for retry: ${err instanceof Error ? err.message : String(err)}`);
1933
+ }
1934
+ }
1935
+ /**
1936
+ * Run every deferred ask whose wait is over (and report the ones that ran
1937
+ * out of road).
1938
+ *
1939
+ * Serialized through `retryChain`: two drains must never run the same ask
1940
+ * twice, and a retry is a full chat/pipeline turn that can take minutes.
1941
+ */
1942
+ async drainDeferredTasks() {
1943
+ // The queue is SHARED with the dashboard server, which serves its own
1944
+ // `dashboard` tasks. This drain owns every OTHER platform (the messaging
1945
+ // channels this process actually has adapters for); claiming a dashboard
1946
+ // session id here would try to deliver it to a messaging platform.
1947
+ const owns = (task) => task.platform !== 'dashboard';
1948
+ // Expiry is reported FIRST: a sender who was told "I'll keep trying" must
1949
+ // hear the outcome even when the queue is busy with something else.
1950
+ for (const task of expiredTasks(Date.now(), owns)) {
1951
+ removeDeferredTask(task.id);
1952
+ const ref = { platform: task.platform, channelId: task.channelId };
1953
+ try {
1954
+ await this.sendToRef(ref, abandonedLine(task));
1955
+ }
1956
+ catch {
1957
+ /* best-effort — the report must not stop the drain */
1958
+ }
1959
+ logGatewayEvent('retry.abandoned', {
1960
+ platform: task.platform,
1961
+ channelId: task.channelId,
1962
+ attempts: task.attempts,
1963
+ confirmed: !!task.confirmed,
1964
+ }, 'warn');
1965
+ }
1966
+ const due = dueTasks(Date.now(), owns);
1967
+ if (due.length === 0)
1968
+ return;
1969
+ this.retryChain = this.retryChain
1970
+ .then(async () => {
1971
+ for (const task of due)
1972
+ await this.runDeferredTask(task);
1973
+ })
1974
+ .catch(() => undefined);
1975
+ await this.retryChain;
1976
+ }
1977
+ /**
1978
+ * Replay ONE deferred ask through the ordinary inbound path.
1979
+ *
1980
+ * Replaying the original message (rather than calling an engine directly) is
1981
+ * deliberate: the retry gets the SAME authorization, the SAME routing
1982
+ * verdict, the SAME pipeline serialization and the SAME reply path as a
1983
+ * first-time ask, so a retry can never behave differently from the request
1984
+ * it is retrying.
1985
+ */
1986
+ async runDeferredTask(task) {
1987
+ const runStart = Date.now();
1988
+ const ref = { platform: task.platform, channelId: task.channelId };
1989
+ const attempt = task.attempts + 1;
1990
+ updateDeferredTask(task.id, {
1991
+ status: 'running',
1992
+ attempts: attempt,
1993
+ lastAttemptAt: runStart,
1994
+ });
1995
+ logger.info(`gateway: retrying deferred ask (attempt ${attempt}) for ${task.platform}:${task.channelId} — ${task.text.slice(0, 60)}`);
1996
+ logGatewayEvent('retry.started', {
1997
+ platform: task.platform,
1998
+ channelId: task.channelId,
1999
+ attempt,
2000
+ }, 'info');
2001
+ // Say so BEFORE the result: a failure report arriving out of nowhere, or an
2002
+ // answer to a request the sender had given up on, both read as a bug. The
2003
+ // line also explains the task in hand, since the original ask may be hours old.
2004
+ try {
2005
+ await this.sendToRef(ref, retryingLine({ ...task, attempts: attempt }));
2006
+ }
2007
+ catch {
2008
+ /* best-effort */
2009
+ }
2010
+ let outcome = '';
2011
+ try {
2012
+ outcome = await this.handleInbound({
2013
+ platform: task.platform,
2014
+ channelId: task.channelId,
2015
+ text: task.text,
2016
+ ...(task.from ? { from: task.from } : {}),
2017
+ ...(task.senderId ? { senderId: task.senderId } : {}),
2018
+ ...(task.isGroup !== undefined ? { isGroup: task.isGroup } : {}),
2019
+ // A FRESH transport id: the dedup ledger consumed the original one, so
2020
+ // reusing it would classify the retry as a duplicate and silently
2021
+ // drop it — the exact "nothing happened" failure this queue exists to fix.
2022
+ messageId: `${task.id}-${attempt}`,
2023
+ }, { retryOf: task.id });
2024
+ }
2025
+ catch (err) {
2026
+ logger.warn(`gateway: deferred retry threw: ${err instanceof Error ? err.message : String(err)}`);
2027
+ }
2028
+ // A policy change can deauthorize the sender between the offer and the
2029
+ // retry. Retrying a refused sender forever would be harassment, so the task
2030
+ // is dropped (silently, matching the policy gate's own contract).
2031
+ if (outcome === 'refused') {
2032
+ removeDeferredTask(task.id);
2033
+ logger.info('gateway: deferred ask dropped — sender no longer authorized.');
2034
+ return;
2035
+ }
2036
+ // Did the replay FAIL again? Then the failure branch re-queued this same
2037
+ // task with a fresh wait, which is the ONLY thing that moves `notBefore`
2038
+ // past the moment we started. Anything else means the ask was answered.
2039
+ const after = getPendingTask(task.platform, task.channelId);
2040
+ if (after && after.id === task.id && after.notBefore > runStart)
2041
+ return;
2042
+ if (after && after.id === task.id) {
2043
+ // No decision was reached (the turn threw before its failure branch):
2044
+ // keep the task with a short backoff rather than dropping a real request.
2045
+ updateDeferredTask(task.id, { status: 'pending', notBefore: Date.now() + 60_000 });
2046
+ return;
2047
+ }
2048
+ removeDeferredTask(task.id);
2049
+ logGatewayEvent('retry.succeeded', {
2050
+ platform: task.platform,
2051
+ channelId: task.channelId,
2052
+ attempts: attempt,
2053
+ }, 'info');
2054
+ }
1669
2055
  /** Stop adapters + unsubscribe + stop the delivery drain. Idempotent. */
1670
2056
  async stop() {
1671
2057
  // Release every awaiting question FIRST, and before the `started` guard: a
@@ -1690,6 +2076,13 @@ export class GatewayRegistry {
1690
2076
  clearInterval(this.deliveryTimer);
1691
2077
  this.deliveryTimer = null;
1692
2078
  }
2079
+ // Deferred tasks are NOT cleared here: they are persisted precisely so a
2080
+ // restart resumes them (and `stop()` is also called on registries that were
2081
+ // never started).
2082
+ if (this.retryTimer) {
2083
+ clearInterval(this.retryTimer);
2084
+ this.retryTimer = null;
2085
+ }
1693
2086
  for (const adapter of this.adapters.values()) {
1694
2087
  try {
1695
2088
  await adapter.stop();