@peopl-health/nexus 5.68.7 → 5.68.9

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.
@@ -65,4 +65,4 @@ function clearCohortCache() {
65
65
  inFlightRead = null;
66
66
  }
67
67
 
68
- module.exports = { isInCohort, clearCohortCache };
68
+ module.exports = { isInCohort, isCohortEnabled, clearCohortCache };
@@ -56,6 +56,7 @@ class ToolRuntimeContext {
56
56
  this.skillActivationIteration = new Map();
57
57
  this.currentIteration = null;
58
58
  this.toolHistory = [];
59
+ this.toolRejections = [];
59
60
  this.safetyArtifact = null;
60
61
  this.palliativeAssessment = null;
61
62
  this.careplan = null;
@@ -93,9 +94,11 @@ class ToolRuntimeContext {
93
94
  this.trace.addStep({ iteration, ...toolData });
94
95
  if (toolData.success) this.toolHistory.push(toolData);
95
96
  if (!toolData.success || isToolFailure(toolData.tool_output)) {
97
+ const code = refusalCode(toolData.tool_output);
98
+ this.toolRejections.push({ tool: toolData.tool_name, code, iteration: iteration ?? null });
96
99
  logger.warn('[toolRuntime] tool call rejected', {
97
100
  tool: toolData.tool_name,
98
- reason: refusalCode(toolData.tool_output),
101
+ reason: code,
99
102
  iteration: iteration ?? null,
100
103
  turnId: this.turnId || null,
101
104
  });
@@ -108,7 +111,7 @@ class ToolRuntimeContext {
108
111
  }
109
112
 
110
113
  async store({ outbound = null, completed = false, predictionTimeMs = null, error = null } = {}) {
111
- this.trace.setSignals({ activatedSkills: [...this.activatedSkills].sort() });
114
+ this.trace.setSignals({ activatedSkills: [...this.activatedSkills].sort(), toolRejections: [...this.toolRejections] });
112
115
  this.trace.setOutbound(outbound || this.trace.outbound || null);
113
116
  const hasError = error !== null && error !== undefined;
114
117
  const isSuperseded = hasError ? false : !this.isLive();
@@ -0,0 +1,59 @@
1
+ const { isCohortEnabled } = require('../config/cohortConfig');
2
+ const { isBridgeEnabled } = require('./bridgeConfig');
3
+ const { contextReadLive, contextMirrorLive, contextVerifierLive } = require('./clinicalContextFlags');
4
+ const { clinicalPatternFloorLive } = require('./clinicalPatternFloorFlags');
5
+ const { isComposerEnabled, isAgentSelfRenderEnabled } = require('./composerFlags');
6
+ const { isContingencyCancelOnReplyLive, isArmOnOpenCeilingLive, isContingencyRuleBlockLive } = require('./contingencyFlags');
7
+ const { escalationAssessmentLive, escalationUpdateModeLive } = require('./escalationFlags');
8
+ const { inboundRecoveryLive, coldInboundMode } = require('./inboundRecoveryFlags');
9
+ const { isLlmAuditEnabled } = require('./llmAuditConfig');
10
+ const { isLlmMonitorEnabled } = require('./llmMonitorConfig');
11
+ const { patternConsultLive } = require('./patternConsultFlags');
12
+ const { recommendationRetryLive } = require('./recommendationRecoveryFlags');
13
+ const { isShadowEnabled } = require('./shadowConfig');
14
+ const { supportLightFromChatLive } = require('./supportLightFlags');
15
+ const { triageGateFloorLive } = require('./triageGateFloorFlags');
16
+ const { triageRecommendationLive, rankComparatorLive } = require('./triageRecommendationFlags');
17
+ const { isConversationalDeclarationLive, isConversationalDraftReuseLive, isDeclarationModelCheckLive } = require('./turnEfficiencyFlags');
18
+
19
+ const FLAG_READERS = {
20
+ cohort: isCohortEnabled,
21
+ bridge: isBridgeEnabled,
22
+ contextRead: contextReadLive,
23
+ contextMirror: contextMirrorLive,
24
+ contextVerifier: contextVerifierLive,
25
+ clinicalPatternFloor: clinicalPatternFloorLive,
26
+ composer: isComposerEnabled,
27
+ agentSelfRender: isAgentSelfRenderEnabled,
28
+ contingencyCancelOnReply: isContingencyCancelOnReplyLive,
29
+ armOnOpenCeiling: isArmOnOpenCeilingLive,
30
+ contingencyRuleBlock: isContingencyRuleBlockLive,
31
+ escalationAssessment: escalationAssessmentLive,
32
+ escalationUpdateMode: escalationUpdateModeLive,
33
+ inboundRecovery: inboundRecoveryLive,
34
+ coldInboundMode,
35
+ llmAudit: isLlmAuditEnabled,
36
+ llmMonitor: isLlmMonitorEnabled,
37
+ patternConsult: patternConsultLive,
38
+ recommendationRetry: recommendationRetryLive,
39
+ shadow: isShadowEnabled,
40
+ supportLightFromChat: supportLightFromChatLive,
41
+ triageGateFloor: triageGateFloorLive,
42
+ triageRecommendation: triageRecommendationLive,
43
+ rankComparator: rankComparatorLive,
44
+ conversationalDeclaration: isConversationalDeclarationLive,
45
+ conversationalDraftReuse: isConversationalDraftReuseLive,
46
+ declarationModelCheck: isDeclarationModelCheckLive,
47
+ };
48
+
49
+ function snapshotClinicalFlags() {
50
+ return Object.fromEntries(Object.entries(FLAG_READERS).map(([name, read]) => {
51
+ try {
52
+ return [name, read()];
53
+ } catch (error) {
54
+ return [name, null];
55
+ }
56
+ }));
57
+ }
58
+
59
+ module.exports = { snapshotClinicalFlags };
@@ -240,7 +240,7 @@ class AnthropicProvider extends BaseLLMProvider {
240
240
  if (lastUserTurn?.role === 'user') {
241
241
  deliveryNudges++;
242
242
  previousRoundTools = [];
243
- trace?.setSignals?.({ deliveryNudges });
243
+ trace?.setSignals?.({ deliveryNudges, nudgeKind: canDecide ? 'outreach' : 'delivery' });
244
244
  logger.warn('[AnthropicProvider] Turn will not close — nudging', { tool: canDecide ? OUTREACH_DECISION_TOOL : 'DeliverPatientMessage', deliveryNudges, hasEnded, isBudgetSpent });
245
245
  const turnContent = Array.isArray(lastUserTurn.content)
246
246
  ? lastUserTurn.content
@@ -10,6 +10,7 @@ const { resolveSkillCatalog } = require('../services/skillService');
10
10
  const { RISK_HEADLINE_MARKER } = require('../services/riskHeadlineService');
11
11
  const { resolveClinicalContext } = require('../services/clinicalContextReadService');
12
12
  const { isContingencyRuleBlockLive } = require('../flags/contingencyFlags');
13
+ const { snapshotClinicalFlags } = require('../flags/flagSnapshot');
13
14
  const { TURN_KIND } = require('../context/turnContext');
14
15
  const { extractPatientMessage } = require('../../helpers/patientMessageHelper');
15
16
  const runtimeConfig = require('../../config/runtimeConfig');
@@ -102,7 +103,7 @@ class BaseLLMProvider {
102
103
  presetId: resolvedPresetId, presetVersion: resolvedPresetVersion, modelConfig,
103
104
  } = await composePrompt({ presetId, presetVersion, promptId: assistantId, variables: promptVariables });
104
105
 
105
- const { toolIds, filtered, descriptions: toolDescriptions } = await resolveTools({
106
+ const { toolIds, filtered, descriptions: toolDescriptions, unregisteredToolIds = [] } = await resolveTools({
106
107
  promptId: resolvedPromptId || assistantId, assistant, presetToolIds,
107
108
  });
108
109
  const requestedAdditionalToolIds = Array.isArray(prePromptResult?.additionalToolIds)
@@ -200,7 +201,12 @@ class BaseLLMProvider {
200
201
  }
201
202
 
202
203
  trace?.setPrompt({ resolvedPresetId, resolvedPresetVersion });
203
- trace?.setSignals?.({ offeredToolIds: effectiveToolIds, enabledSkills });
204
+ trace?.setSignals?.({
205
+ offeredToolIds: effectiveToolIds,
206
+ enabledSkills,
207
+ flags: snapshotClinicalFlags(),
208
+ ...(unregisteredToolIds.length > 0 && { unregisteredToolIds }),
209
+ });
204
210
 
205
211
  const { response, toolsExecuted, retries, usage } = await this._invokeModel({
206
212
  instructions: instructions || devContent || '',
@@ -191,6 +191,7 @@ async function fetchToolsByRecordIds(recordIds) {
191
191
  return tools;
192
192
  }
193
193
 
194
+ const BLANK_TOOL_ID = '(blank tool_id)';
194
195
  const warnedUnlinked = new Set();
195
196
 
196
197
  function warnUnlinkedRegisteredTools(linkedToolIds, promptId) {
@@ -228,6 +229,7 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
228
229
  const activeTools = mappedTools.filter(t => !t.status || t.status === 'prod' || (!isProd && t.status === 'dev'));
229
230
 
230
231
  const validToolIds = [];
232
+ const unregisteredToolIds = [];
231
233
  const descriptions = {};
232
234
 
233
235
  for (const tool of activeTools) {
@@ -237,6 +239,7 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
237
239
  // An Airtable column retyped away from long text would otherwise reach the model as [object Object].
238
240
  if (typeof tool.description === 'string' && tool.description) descriptions[id] = tool.description;
239
241
  } else {
242
+ unregisteredToolIds.push(id || BLANK_TOOL_ID);
240
243
  logger.error('[promptComposer] Tool mapped in Airtable but not registered in code', { toolId: id, promptId });
241
244
  }
242
245
  }
@@ -252,7 +255,7 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
252
255
 
253
256
  logger.debug('[promptComposer] Tools resolved', { promptId, mapped: activeTools.length, valid: validToolIds.length, filtered: true });
254
257
 
255
- return { toolIds: validToolIds, filtered: true, descriptions };
258
+ return { toolIds: validToolIds, filtered: true, descriptions, unregisteredToolIds };
256
259
  }
257
260
 
258
261
  function clearCache() {
@@ -98,6 +98,7 @@ const draftSchema = new mongoose.Schema({
98
98
  missingCharacterization: { type: [String], default: undefined },
99
99
  suggestCall: { type: suggestCallSchema, default: undefined },
100
100
  sectionsUnavailable: { type: [String], default: undefined },
101
+ snapshotAt: { type: Date, default: undefined },
101
102
  }, { timestamps: { createdAt: true, updatedAt: false } });
102
103
 
103
104
  const doctorHandoffSchema = new mongoose.Schema({
@@ -44,7 +44,7 @@ const AUDIT_SOURCE = 'escalation_review';
44
44
  const RECENT_REVIEW_LIMIT = 20;
45
45
  const TIMELINE_LIMIT = 500;
46
46
  const MAX_REVIEW_UPDATES = 50;
47
- const DRAFT_DEBOUNCE_MS = 60 * 1000;
47
+ const DRAFT_DEBOUNCE_MS = 20 * 1000;
48
48
  const DRAFT_OPERATOR = 'bot:escalationDraft';
49
49
  const DIRECT_CHAT_ATTEMPTS = 3;
50
50
  const RECHECK_DELAY_MS = 60 * 1000;
@@ -1098,6 +1098,7 @@ async function settleFollowUp(snapshot, { background = true } = {}) {
1098
1098
  }
1099
1099
 
1100
1100
  async function generateBotDraft({ id, lease = null } = {}) {
1101
+ const startedAt = new Date();
1101
1102
  const review = await loadReview(reviewIdOf(id));
1102
1103
  if (review.closedAt || !DOCUMENTING_STATUSES.includes(review.status)) return { review: null, retry: false };
1103
1104
  const latest = (review.drafts ?? []).at(-1);
@@ -1127,6 +1128,7 @@ async function generateBotDraft({ id, lease = null } = {}) {
1127
1128
  }
1128
1129
 
1129
1130
  const entry = {
1131
+ snapshotAt: startedAt,
1130
1132
  doctorText: withIdentity({ identity, review, doctorText: draft.doctorText }),
1131
1133
  patientMessage: draft.patientMessage,
1132
1134
  writtenBy: DRAFT_OPERATOR,
@@ -1137,13 +1139,16 @@ async function generateBotDraft({ id, lease = null } = {}) {
1137
1139
  ...(draft.suggestCall && { suggestCall: draft.suggestCall }),
1138
1140
  ...(draft.sectionsUnavailable?.length && { sectionsUnavailable: draft.sectionsUnavailable }),
1139
1141
  };
1140
- const saved = await getEscalationReview().findOneAndUpdate(
1141
- { _id: review._id, status: { $in: DOCUMENTING_STATUSES }, draftDueAt: lease, drafts: { $size: (review.drafts ?? []).length } },
1142
- { $push: { drafts: entry }, $set: { status: 'draft_ready', draftDueAt: null, draftFailedAt: null }, $unset: { identityBlock: '' } },
1142
+ const EscalationReview = getEscalationReview();
1143
+ const saved = await EscalationReview.findOneAndUpdate(
1144
+ { _id: review._id, status: { $in: DOCUMENTING_STATUSES }, draftDueAt: { $lte: lease }, drafts: { $size: (review.drafts ?? []).length } },
1145
+ { $push: { drafts: entry }, $set: { status: 'draft_ready', draftFailedAt: null }, $unset: { identityBlock: '' } },
1143
1146
  { new: true },
1144
1147
  ).lean();
1145
- if (saved) await audit(saved, DRAFT_OPERATOR, null, { action: 'draft_saved', draftNumber: saved.drafts.length });
1146
- return { review: saved, retry: false };
1148
+ if (!saved) return { review: null, retry: false };
1149
+ const released = await EscalationReview.updateOne({ _id: review._id, draftDueAt: lease }, { $set: { draftDueAt: null } }, BOOKKEEPING);
1150
+ await audit(saved, DRAFT_OPERATOR, null, { action: 'draft_saved', draftNumber: saved.drafts.length });
1151
+ return { review: released.matchedCount ? { ...saved, draftDueAt: null } : saved, retry: false };
1147
1152
  }
1148
1153
 
1149
1154
  async function noteFactsContext(review) {
@@ -1361,7 +1366,7 @@ async function handOffToDoctor({ id, mode, physicianEmail = null, sentBy, reques
1361
1366
  if (!approver) throw httpError(403, 'The physician is not an escalation review approver');
1362
1367
  }
1363
1368
 
1364
- const draftedAt = draft.createdAt ? new Date(draft.createdAt).getTime() : null;
1369
+ const draftedAt = draftTimeOf(draft) ? new Date(draftTimeOf(draft)).getTime() : null;
1365
1370
  if (draftedAt && (review.alarms ?? []).some((alarm) => new Date(alarm.raisedAt).getTime() > draftedAt)) {
1366
1371
  throw httpError(409, 'A new alarm arrived after this draft was written; review it and save a new draft before sending it to the doctor');
1367
1372
  }
@@ -1457,7 +1462,7 @@ async function recordDoctorDecision({
1457
1462
  // Truthiness, not != null: an empty textarea is a decline with nothing to say.
1458
1463
  if (decision === 'needs_intervention' && finalText) approvedText = requiredText(finalText, 'finalText');
1459
1464
 
1460
- if (APPROVALS.includes(decision) && arrivedAfter(review, draft.createdAt)) {
1465
+ if (APPROVALS.includes(decision) && arrivedAfter(review, draftTimeOf(draft))) {
1461
1466
  if (review.status === 'awaiting_doctor') {
1462
1467
  const redraft = draft.writtenBy === DRAFT_OPERATOR ? { draftDueAt: new Date(), draftAttempts: 0 } : {};
1463
1468
  const returned = await updateReview(reviewId, ['awaiting_doctor'], { $set: { status: 'documenting', ...redraft } }, {
@@ -1572,6 +1577,8 @@ const latestUpdateGuard = (review) => {
1572
1577
  return { updates: { $size: updates.length }, [`updates.${updates.length - 1}.turnId`]: updates.at(-1).turnId };
1573
1578
  };
1574
1579
 
1580
+ const draftTimeOf = (draft) => draft.snapshotAt ?? draft.createdAt;
1581
+
1575
1582
  const arrivedAfter = (review, since) => Boolean(since) && [
1576
1583
  ...(review.alarms ?? []).map((alarm) => alarm.raisedAt),
1577
1584
  ...(review.updates ?? []).map((update) => update.at),
@@ -6,7 +6,7 @@ const { getEscalationReview } = require('../models/escalationReviewModel');
6
6
  const { BOOKKEEPING, DOCUMENTING_STATUSES, generateBotDraft } = require('./escalationReviewService');
7
7
 
8
8
  const SWEEP_LIMIT = 1;
9
- const DEFAULT_SWEEP_INTERVAL_MS = 30 * 1000;
9
+ const DEFAULT_SWEEP_INTERVAL_MS = 10 * 1000;
10
10
  const RETRY_DELAY_MS = 5 * 60 * 1000;
11
11
  const MAX_DRAFT_ATTEMPTS = 3;
12
12
  const LEASE_MS = 10 * 60 * 1000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.68.7",
3
+ "version": "5.68.9",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",