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.
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +17 -2
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/nlu.d.ts +8 -0
- package/dist/cli/nlu.d.ts.map +1 -1
- package/dist/cli/nlu.js +57 -0
- package/dist/cli/nlu.js.map +1 -1
- package/dist/gateway/gateway-log.d.ts +1 -1
- package/dist/gateway/gateway-log.d.ts.map +1 -1
- package/dist/gateway/gateway-log.js.map +1 -1
- package/dist/gateway/registry.d.ts +85 -1
- package/dist/gateway/registry.d.ts.map +1 -1
- package/dist/gateway/registry.js +438 -45
- package/dist/gateway/registry.js.map +1 -1
- package/dist/inference/tool-call-utils.d.ts.map +1 -1
- package/dist/inference/tool-call-utils.js +70 -1
- package/dist/inference/tool-call-utils.js.map +1 -1
- package/dist/learning/deferred-task.d.ts +159 -0
- package/dist/learning/deferred-task.d.ts.map +1 -0
- package/dist/learning/deferred-task.js +451 -0
- package/dist/learning/deferred-task.js.map +1 -0
- package/dist/learning/model-first-router.d.ts.map +1 -1
- package/dist/learning/model-first-router.js +8 -0
- package/dist/learning/model-first-router.js.map +1 -1
- package/dist/learning/model-registry.d.ts +30 -0
- package/dist/learning/model-registry.d.ts.map +1 -1
- package/dist/learning/model-registry.js +61 -0
- package/dist/learning/model-registry.js.map +1 -1
- package/dist/learning/resilient-call.d.ts.map +1 -1
- package/dist/learning/resilient-call.js +38 -3
- package/dist/learning/resilient-call.js.map +1 -1
- package/dist/nlu/conversation-gate.d.ts +22 -0
- package/dist/nlu/conversation-gate.d.ts.map +1 -1
- package/dist/nlu/conversation-gate.js +29 -6
- package/dist/nlu/conversation-gate.js.map +1 -1
- package/dist/nlu/intent-confirm.d.ts +82 -0
- package/dist/nlu/intent-confirm.d.ts.map +1 -0
- package/dist/nlu/intent-confirm.js +146 -0
- package/dist/nlu/intent-confirm.js.map +1 -0
- package/dist/nlu/learnings.d.ts +101 -0
- package/dist/nlu/learnings.d.ts.map +1 -0
- package/dist/nlu/learnings.js +283 -0
- package/dist/nlu/learnings.js.map +1 -0
- package/dist/tools/tool-loop.d.ts.map +1 -1
- package/dist/tools/tool-loop.js +16 -2
- package/dist/tools/tool-loop.js.map +1 -1
- package/dist/web-dashboard/chat-console.d.ts +29 -0
- package/dist/web-dashboard/chat-console.d.ts.map +1 -1
- package/dist/web-dashboard/chat-console.js +97 -18
- package/dist/web-dashboard/chat-console.js.map +1 -1
- package/dist/web-dashboard/chat-retry.d.ts +143 -0
- package/dist/web-dashboard/chat-retry.d.ts.map +1 -0
- package/dist/web-dashboard/chat-retry.js +219 -0
- package/dist/web-dashboard/chat-retry.js.map +1 -0
- package/dist/web-dashboard/server.d.ts +17 -0
- package/dist/web-dashboard/server.d.ts.map +1 -1
- package/dist/web-dashboard/server.js +120 -1
- package/dist/web-dashboard/server.js.map +1 -1
- package/package.json +1 -1
- package/src/web-dashboard/public/assets/{index-CQW0qv1r.js → index-Co7Hk2FT.js} +27 -27
- package/src/web-dashboard/public/assets/index-Co7Hk2FT.js.map +1 -0
- package/src/web-dashboard/public/index.html +1 -1
- package/src/web-dashboard/public/assets/index-CQW0qv1r.js.map +0 -1
package/dist/gateway/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
973
|
-
?
|
|
1023
|
+
const breadth = this.hasConfiguredModel()
|
|
1024
|
+
? modelBreadthReport(attemptMark, this.configManager)
|
|
974
1025
|
: undefined;
|
|
975
|
-
const
|
|
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
|
|
989
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
:
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
},
|
|
1042
|
-
|
|
1043
|
-
//
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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();
|