@peopl-health/nexus 5.11.0-dev.1131 → 5.11.0-dev.1132

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.
@@ -8,7 +8,7 @@ const { overlayCohortPreset } = require('./helpers/cohortHelper');
8
8
  const { forkShadowTurn } = require('./services/shadowService');
9
9
  const { diverge } = require('./services/divergenceService');
10
10
  const { getAssistantById } = require('./services/assistantResolver');
11
- const { maybeSendBridge } = require('./services/bridgeService');
11
+ const { maybeSendBridge, clearBridgeState } = require('./services/bridgeService');
12
12
  const { getBridgePresetId } = require('./flags/bridgeConfig');
13
13
  const { ToolRuntimeContext } = require('./context/ToolRuntimeContext');
14
14
 
@@ -45,7 +45,11 @@ class AssistantProcessor {
45
45
 
46
46
  if (thread.isInCohort && !runOptions.toolRuntimeContext && runOptions.runId && thread.code) {
47
47
  try {
48
- runOptions.toolRuntimeContext = new ToolRuntimeContext({ patientCode: thread.code, turnId: runOptions.runId });
48
+ runOptions.toolRuntimeContext = new ToolRuntimeContext({
49
+ patientCode: thread.code,
50
+ turnId: runOptions.runId,
51
+ shouldContinue: runOptions.shouldContinue,
52
+ });
49
53
  } catch (error) {
50
54
  logger.warn('[AssistantProcessor] turn will run untraced', { code: thread.code, error: error.message });
51
55
  }
@@ -83,7 +87,6 @@ class AssistantProcessor {
83
87
  outbound: output,
84
88
  completed: runResult?.completed,
85
89
  predictionTimeMs,
86
- shouldContinue: runOptions.shouldContinue,
87
90
  });
88
91
  } catch (error) {
89
92
  logger.warn('[AssistantProcessor] trace not stored', {
@@ -136,6 +139,7 @@ class AssistantProcessor {
136
139
  if (!this.sendMessage) throw new Error('sendMessage function not configured');
137
140
  if (!result?.output) return null;
138
141
  await this.sendMessage({ code, body: result.output, processed: true, origin: 'assistant', tools_executed: result.tools_executed, prompt: result.prompt, preset: result.preset, response_id: result.response_id, turnId: result.turnId, traceId: result.traceId });
142
+ clearBridgeState(code);
139
143
  return result.output;
140
144
  }
141
145
  }
@@ -8,6 +8,7 @@ class ToolRuntimeContext {
8
8
  repos = {},
9
9
  contextSource = null,
10
10
  shadow = false,
11
+ shouldContinue = null,
11
12
  } = {}) {
12
13
  if (typeof patientCode !== 'string' || !patientCode) {
13
14
  throw new Error('ToolRuntimeContext requires a non-empty patientCode');
@@ -22,6 +23,7 @@ class ToolRuntimeContext {
22
23
  this.patientCode = patientCode;
23
24
  this.turnId = turnId;
24
25
  this.sessionId = sessionId;
26
+ this.shouldContinue = shouldContinue;
25
27
  this.repos = repos;
26
28
  this.contextSource = contextSource;
27
29
  this.enabledSkills = [];
@@ -42,13 +44,17 @@ class ToolRuntimeContext {
42
44
  return toolData;
43
45
  }
44
46
 
45
- async store({ outbound = null, completed = false, predictionTimeMs = null, shouldContinue = null } = {}) {
47
+ isLive() {
48
+ return typeof this.shouldContinue !== 'function' || this.shouldContinue();
49
+ }
50
+
51
+ async store({ outbound = null, completed = false, predictionTimeMs = null } = {}) {
46
52
  this.trace.setSignals({ activatedSkills: [...this.activatedSkills].sort() });
47
53
  this.trace.setOutbound(outbound);
48
- this.trace.setStatus(completed ? 'completed' : 'incomplete');
54
+ const superseded = !this.isLive();
55
+ this.trace.setStatus(superseded ? 'superseded' : (completed ? 'completed' : 'incomplete'));
49
56
  this.trace.setTimings({ predictionTimeMs });
50
- const superseded = shouldContinue && !shouldContinue();
51
- if (!superseded) await this.trace.save();
57
+ await this.trace.save();
52
58
  }
53
59
 
54
60
  setSafetyArtifact(artifact) {
@@ -11,7 +11,7 @@ const { logger } = require('../../utils/logger');
11
11
 
12
12
  const runtimeConfig = require('../../config/runtimeConfig');
13
13
 
14
- const { BaseLLMProvider, CONVERSATION_CONTINUATION, DELIVERY_NUDGE, MAX_DELIVERY_NUDGES, TERMINAL_DELIVERY_TOOLS } = require('./BaseLLMProvider');
14
+ const { BaseLLMProvider, CONVERSATION_CONTINUATION, DELIVERY_NUDGE, MAX_DELIVERY_NUDGES, TERMINAL_DELIVERY_TOOLS, isSuperseded } = require('./BaseLLMProvider');
15
15
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
16
16
 
17
17
  const DEFAULT_MAX_FUNCTION_ROUNDS = 12;
@@ -69,7 +69,7 @@ class AnthropicProvider extends BaseLLMProvider {
69
69
  async _invokeModel({
70
70
  instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
71
71
  modelConfig, assistant, phiProcessor = null, trace = null, onFirstIteration = null,
72
- resolvedPromptId = null, resolvedPresetId = null
72
+ resolvedPromptId = null, resolvedPresetId = null, shouldContinue = null
73
73
  }, options = {}) {
74
74
  const executeTools = options.toolExecutor || handleFunctionCalls;
75
75
  let totalRetries = 0;
@@ -170,7 +170,7 @@ class AnthropicProvider extends BaseLLMProvider {
170
170
  && (response.content || []).some(block => block.type === 'tool_use');
171
171
  try {
172
172
  const decision = onFirstIteration({ narrationText, hasToolCalls });
173
- if (decision?.sent && decision.dedupNote) bridgeNote = decision.dedupNote;
173
+ if (decision?.dedupNote) bridgeNote = decision.dedupNote;
174
174
  } catch (err) {
175
175
  logger.warn('[AnthropicProvider] bridge callback failed', { error: err.message });
176
176
  }
@@ -187,6 +187,10 @@ class AnthropicProvider extends BaseLLMProvider {
187
187
  let deliveryBreak = false;
188
188
  const maxIterations = this.maxFunctionRounds + MAX_DELIVERY_NUDGES + LOOP_ITERATION_HEADROOM;
189
189
  for (let iteration = 1; iteration <= maxIterations; iteration++) {
190
+ if (isSuperseded(shouldContinue)) {
191
+ logger.info('[AnthropicProvider] turn superseded — aborting tool loop', { iteration });
192
+ break;
193
+ }
190
194
  const content = finalResponse.content || [];
191
195
  const toolUses = content.filter(block => block.type === 'tool_use');
192
196
  const ended = finalResponse.stop_reason !== 'tool_use' || !toolUses.length;
@@ -251,9 +255,9 @@ class AnthropicProvider extends BaseLLMProvider {
251
255
  }
252
256
 
253
257
  // Budget exhausted mid-work: the loop stopped while the model still wanted tools, so the reply may be empty. Make it
254
- // visible — but not on a delivery break, which also exits with pending calls and is not exhaustion.
258
+ // visible — but not on a supersede or delivery break, which also exit with pending calls and are not exhaustion.
255
259
  const pendingTools = (finalResponse.content || []).filter(block => block.type === 'tool_use');
256
- if (finalResponse.stop_reason === 'tool_use' && pendingTools.length && !deliveryBreak) {
260
+ if (finalResponse.stop_reason === 'tool_use' && pendingTools.length && !deliveryBreak && !isSuperseded(shouldContinue)) {
257
261
  logger.warn('[AnthropicProvider] Tool-iteration budget exhausted with pending tool calls', { maxFunctionRounds: this.maxFunctionRounds, pending: pendingTools.map(b => b.name) });
258
262
  trace?.setSignals?.({ toolBudgetExhausted: true });
259
263
  }
@@ -18,6 +18,10 @@ const TERMINAL_DELIVERY_TOOLS = new Set(['DeliverPatientMessage']);
18
18
  const DELIVERY_NUDGE = 'sistema: el turno no puede cerrar sin un mensaje para el paciente. Llama ahora a DeliverPatientMessage con message_text en español. No repitas herramientas clínicas ya ejecutadas en este turno.';
19
19
  const MAX_DELIVERY_NUDGES = 1;
20
20
 
21
+ function isSuperseded(shouldContinue) {
22
+ return typeof shouldContinue === 'function' && !shouldContinue();
23
+ }
24
+
21
25
  class BaseLLMProvider {
22
26
  constructor(options = {}) {
23
27
  if (new.target === BaseLLMProvider) {
@@ -332,8 +336,8 @@ class BaseLLMProvider {
332
336
  return result;
333
337
  }
334
338
 
335
- if (config.shadow && !config.shouldContinue()) {
336
- logger.info('[runConversation] Shadow superseded — returning without retry', { attempt });
339
+ if (isSuperseded(config.shouldContinue)) {
340
+ logger.info('[runConversation] Superseded — returning without retry', { attempt, shadow: config.shadow });
337
341
  return result;
338
342
  }
339
343
 
@@ -344,8 +348,8 @@ class BaseLLMProvider {
344
348
  await new Promise(r => setTimeout(r, 500));
345
349
  } catch (error) {
346
350
  logger.error('[runConversation] Attempt failed', { attempt, error: error.message });
347
- if (config.shadow && !config.shouldContinue()) {
348
- logger.info('[runConversation] Shadow superseded — abandoning after error', { attempt });
351
+ if (isSuperseded(config.shouldContinue)) {
352
+ logger.info('[runConversation] Superseded — abandoning after error', { attempt, shadow: config.shadow });
349
353
  return { status: 'cancelled', output_text: '', tools_executed: [], retries: 0 };
350
354
  }
351
355
  if (attempt === maxRetries) throw error;
@@ -528,4 +532,5 @@ module.exports = {
528
532
  DELIVERY_NUDGE,
529
533
  MAX_DELIVERY_NUDGES,
530
534
  TERMINAL_DELIVERY_TOOLS,
535
+ isSuperseded,
531
536
  };
@@ -9,7 +9,7 @@ const { logger } = require('../../utils/logger');
9
9
 
10
10
  const runtimeConfig = require('../../config/runtimeConfig');
11
11
 
12
- const { BaseLLMProvider } = require('./BaseLLMProvider');
12
+ const { BaseLLMProvider, isSuperseded } = require('./BaseLLMProvider');
13
13
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
14
14
 
15
15
  const DEFAULT_MAX_FUNCTION_ROUNDS = 12;
@@ -69,7 +69,7 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
69
69
  shadow = false, shouldContinue = null, onFirstIteration = null
70
70
  }, options = {}) {
71
71
  const executeTools = options.toolExecutor || handleFunctionCalls;
72
- const shadowSuperseded = () => shadow && !shouldContinue();
72
+ const superseded = () => isSuperseded(shouldContinue);
73
73
  let totalRetries = 0;
74
74
  const allToolsExecuted = [];
75
75
  const allToolCalls = [];
@@ -127,7 +127,7 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
127
127
  const hasToolCalls = Boolean(assistant) && (response.output || []).some(item => item.type === 'function_call');
128
128
  try {
129
129
  const decision = onFirstIteration({ narrationText, hasToolCalls });
130
- if (decision?.sent && decision.dedupNote) bridgeNote = decision.dedupNote;
130
+ if (decision?.dedupNote) bridgeNote = decision.dedupNote;
131
131
  } catch (err) {
132
132
  logger.warn('[OpenAIResponsesProvider] bridge callback failed', { error: err.message });
133
133
  }
@@ -140,8 +140,8 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
140
140
  apiCallConfig.tool_choice = followUpToolChoice;
141
141
 
142
142
  for (let round = 1; round <= this.maxFunctionRounds; round++) {
143
- if (shadowSuperseded()) {
144
- logger.info('[OpenAIResponsesProvider] shadow superseded — aborting tool loop', { round });
143
+ if (superseded()) {
144
+ logger.info('[OpenAIResponsesProvider] turn superseded — aborting tool loop', { round, shadow });
145
145
  break;
146
146
  }
147
147
  const functionCalls = finalResponse.output.filter(item => item.type === 'function_call');
@@ -167,9 +167,9 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
167
167
  }
168
168
 
169
169
  // Budget exhausted mid-work: the loop stopped while the model still wanted tools, so the reply may be empty. Make it
170
- // visible — but not on a shadow-supersede or delivery break, which also exit with pending calls and are not exhaustion.
170
+ // visible — but not on a supersede or delivery break, which also exit with pending calls and are not exhaustion.
171
171
  const pendingCalls = (finalResponse.output || []).filter(item => item.type === 'function_call');
172
- if (pendingCalls.length && !shadowSuperseded() && !deliveryBreak) {
172
+ if (pendingCalls.length && !superseded() && !deliveryBreak) {
173
173
  logger.warn('[OpenAIResponsesProvider] Tool-iteration budget exhausted with pending tool calls', { maxFunctionRounds: this.maxFunctionRounds, pending: pendingCalls.map(c => c.name) });
174
174
  trace?.setSignals?.({ toolBudgetExhausted: true });
175
175
  }
@@ -1,10 +1,12 @@
1
1
  const { OpenAI } = require('openai');
2
2
 
3
+ const { logger } = require('../../utils/logger');
3
4
  const { retryWithBackoff } = require('../../utils/retryUtils');
4
5
  const { withTiming } = require('../../utils/tracingDecorator');
5
6
  const { safeParse } = require('../../utils/jsonUtils');
6
7
 
7
8
  const { OpenAIResponsesProvider } = require('./OpenAIResponsesProvider');
9
+ const { isSuperseded } = require('./BaseLLMProvider');
8
10
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
9
11
 
10
12
  const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
@@ -37,7 +39,7 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
37
39
 
38
40
  async _invokeModel({
39
41
  instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
40
- modelConfig, assistant, phiProcessor = null, trace = null
42
+ modelConfig, assistant, phiProcessor = null, trace = null, shouldContinue = null
41
43
  }, options = {}) {
42
44
  const executeTools = options.toolExecutor || handleFunctionCalls;
43
45
  let totalRetries = 0;
@@ -74,6 +76,10 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
74
76
  apiCallConfig.tool_choice = followUpToolChoice;
75
77
 
76
78
  for (let round = 1; round <= this.maxFunctionRounds; round++) {
79
+ if (isSuperseded(shouldContinue)) {
80
+ logger.info('[OpenRouterProvider] turn superseded — aborting tool loop', { round });
81
+ break;
82
+ }
77
83
  const message = finalResponse.choices?.[0]?.message;
78
84
  const toolCalls = message?.tool_calls || [];
79
85
  if (!toolCalls.length) break;
@@ -25,9 +25,9 @@ function buildDedupNote(bridgeText) {
25
25
  function maybeSendBridge({ narrationText, hasToolCalls, code, turnId = null, traceId = null, shouldContinue, sendMessage }) {
26
26
  if (!isBridgeEnabled()) return { sent: false };
27
27
 
28
- const skip = (reason) => {
28
+ const skip = (reason, extra = {}) => {
29
29
  logger.info('[bridge] skipped', { code, reason });
30
- return { sent: false };
30
+ return { sent: false, ...extra };
31
31
  };
32
32
 
33
33
  if (typeof shouldContinue === 'function' && !shouldContinue()) return skip('superseded');
@@ -36,20 +36,44 @@ function maybeSendBridge({ narrationText, hasToolCalls, code, turnId = null, tra
36
36
  const text = sanitizeBridgeText(narrationText);
37
37
  if (!text) return skip('no_usable_narration');
38
38
 
39
- if (bridgeTimestamps.get(code) !== undefined) return skip('recent_bridge_in_thread');
39
+ const pending = bridgeTimestamps.get(code);
40
+ if (pending !== undefined) {
41
+ return skip('recent_bridge_in_thread', pending.delivered ? { dedupNote: pending.dedupNote } : {});
42
+ }
40
43
 
41
- bridgeTimestamps.set(code, true);
44
+ const dedupNote = buildDedupNote(text);
45
+ const entry = { dedupNote, delivered: false, clearWhenDelivered: false };
46
+ bridgeTimestamps.set(code, entry);
47
+ const failed = (err) => {
48
+ bridgeTimestamps.delete(code);
49
+ logger.warn('[bridge] send failed', { code, error: err.message });
50
+ };
51
+ const delivered = () => {
52
+ entry.delivered = true;
53
+ if (entry.clearWhenDelivered && bridgeTimestamps.get(code) === entry) bridgeTimestamps.delete(code);
54
+ };
42
55
  try {
43
56
  Promise.resolve(sendMessage({ code, body: text, processed: true, origin: 'assistant', raw: { bridge: true }, turnId, traceId }))
44
- .catch(err => logger.warn('[bridge] send failed', { code, error: err.message }));
57
+ .then(delivered)
58
+ .catch(failed);
45
59
  } catch (err) {
46
- logger.warn('[bridge] send failed', { code, error: err.message });
60
+ failed(err);
47
61
  }
48
62
 
49
63
  logger.info('[bridge] sent', { code });
50
- return { sent: true, text, dedupNote: buildDedupNote(text) };
64
+ return { sent: true, text, dedupNote };
65
+ }
66
+
67
+ function clearBridgeState(code) {
68
+ const pending = bridgeTimestamps.get(code);
69
+ if (pending && !pending.delivered) {
70
+ pending.clearWhenDelivered = true;
71
+ return;
72
+ }
73
+ bridgeTimestamps.delete(code);
51
74
  }
52
75
 
53
76
  module.exports = {
54
77
  maybeSendBridge,
78
+ clearBridgeState,
55
79
  };
@@ -21,16 +21,21 @@ const INTAKE_SOURCE_TO_AIRTABLE = {
21
21
  team_relay: 'doctor',
22
22
  };
23
23
 
24
- async function auditedWrite({ trace, code, table, write }) {
24
+ async function auditedWrite({ trace, code, table, write, isLive = null }) {
25
25
  const entry = { table, at: new Date().toISOString(), ok: false };
26
- try {
27
- const result = await write();
28
- entry.ok = true;
29
- const record = Array.isArray(result) ? result[0] : result;
30
- if (record?.id) entry.recordId = record.id;
31
- } catch (err) {
32
- entry.error = err?.message || String(err);
33
- logger.error('[clinicalAirtableService] Airtable write failed', { table, code, error: entry.error });
26
+ if (isLive && !isLive()) {
27
+ entry.skipped = 'superseded';
28
+ logger.info('[clinicalAirtableService] Airtable write skipped — turn superseded', { table, code });
29
+ } else {
30
+ try {
31
+ const result = await write();
32
+ entry.ok = true;
33
+ const record = Array.isArray(result) ? result[0] : result;
34
+ if (record?.id) entry.recordId = record.id;
35
+ } catch (err) {
36
+ entry.error = err?.message || String(err);
37
+ logger.error('[clinicalAirtableService] Airtable write failed', { table, code, error: entry.error });
38
+ }
34
39
  }
35
40
  if (trace?.setSignals) {
36
41
  const existing = Array.isArray(trace.signals?.airtableWrites) ? trace.signals.airtableWrites : [];
@@ -92,10 +97,11 @@ async function recordEmergency({ code, trace, symptom, suspectedCondition = '',
92
97
  });
93
98
  }
94
99
 
95
- async function recordClinicalIntake({ code, trace, messageRaw, source = 'self' }) {
100
+ async function recordClinicalIntake({ code, trace, messageRaw, source = 'self', isLive = null }) {
96
101
  return auditedWrite({
97
102
  trace,
98
103
  code,
104
+ isLive,
99
105
  table: 'intake',
100
106
  write: async () => {
101
107
  const patientId = await findIntakePatientRecordId(code);
@@ -110,12 +116,13 @@ async function recordClinicalIntake({ code, trace, messageRaw, source = 'self' }
110
116
  });
111
117
  }
112
118
 
113
- async function recordReminder({ code, trace, actionType, reminderId, reminderDateUtc, reminderType, description }) {
119
+ async function recordReminder({ code, trace, actionType, reminderId, reminderDateUtc, reminderType, description, isLive = null }) {
114
120
  const type = REMINDER_TYPE_TO_AIRTABLE[reminderType] || reminderType;
115
121
  if (actionType === 'update') {
116
122
  return auditedWrite({
117
123
  trace,
118
124
  code,
125
+ isLive,
119
126
  table: 'reminder',
120
127
  write: async () => {
121
128
  const updated = await airtableService.updateRecordByFilter(
@@ -133,6 +140,7 @@ async function recordReminder({ code, trace, actionType, reminderId, reminderDat
133
140
  return auditedWrite({
134
141
  trace,
135
142
  code,
143
+ isLive,
136
144
  table: 'reminder',
137
145
  write: () => airtableService.addLinkedRecord(
138
146
  Monitoreo_ID,
@@ -27,7 +27,7 @@ async function runShadowTurn({
27
27
  const shadowAssistant = resolveAssistant(shadowThread);
28
28
 
29
29
  const context = new ToolRuntimeContext({
30
- patientCode: code, turnId: runId, sessionId, shadow: true,
30
+ patientCode: code, turnId: runId, sessionId, shadow: true, shouldContinue,
31
31
  });
32
32
 
33
33
  const runOptions = {
@@ -11,59 +11,26 @@ const TOOLS_TABLE = 'tools';
11
11
  const CACHE_TTL = 5 * 60 * 1000;
12
12
  const CACHE_KEY = 'skills';
13
13
  const TOOLS_CACHE_KEY = 'toolCatalog';
14
- const BACKTICK_SPAN_RX = /`([^`\n]+)`/g;
15
- const LEADING_CALL_RX = /^([A-Za-z][A-Za-z0-9]*)\s*(?:[(.]|$)/;
16
- const MULTI_WORD_RX = /[a-z][A-Z]/;
17
14
 
18
15
  const cache = new MapCache({ maxSize: 2, ttl: CACHE_TTL });
19
16
 
20
- async function readToolCatalog() {
21
- const cached = cache.get(TOOLS_CACHE_KEY);
22
- if (cached) return cached;
23
-
24
- let records;
25
- try {
26
- records = await getRecordByFilter(Config_ID, TOOLS_TABLE, 'TRUE()');
27
- } catch (error) {
28
- logger.warn('[skillService] tool catalog unreadable; drift check falls back to the skill body', { error: error.message });
29
- return null;
30
- }
31
- if (!records) {
32
- logger.warn('[skillService] tool catalog unreadable; drift check falls back to the skill body', { table: TOOLS_TABLE });
33
- return null;
34
- }
35
-
36
- const catalog = records.map((row) => row.tool_id).filter(Boolean);
37
- cache.set(TOOLS_CACHE_KEY, catalog);
38
- return catalog;
39
- }
40
-
41
17
  async function unavailableToolNames() {
42
- const catalog = await readToolCatalog();
43
- if (!catalog) return [];
18
+ let catalog = cache.get(TOOLS_CACHE_KEY);
19
+ if (!catalog) {
20
+ const records = await getRecordByFilter(Config_ID, TOOLS_TABLE, 'TRUE()');
21
+ catalog = (records || []).map((row) => row.tool_id).filter(Boolean);
22
+ cache.set(TOOLS_CACHE_KEY, catalog);
23
+ }
44
24
  const registered = new Set(getRegisteredToolNames());
45
25
  return catalog.filter((id) => !registered.has(id));
46
26
  }
47
27
 
48
- function mentionedToolNames(text) {
49
- const names = new Set();
50
- for (const [, span] of text.matchAll(BACKTICK_SPAN_RX)) {
51
- const id = span.match(LEADING_CALL_RX)?.[1];
52
- if (id && MULTI_WORD_RX.test(id)) names.add(id);
53
- }
54
- return names;
55
- }
56
-
57
28
  async function warnOnRegistryDrift(name, body) {
58
- const registeredNames = getRegisteredToolNames();
59
- if (registeredNames.length === 0) return;
60
- const registered = new Set(registeredNames);
29
+ if (getRegisteredToolNames().length === 0) return;
61
30
  const text = String(body || '');
62
-
63
- const fromCatalog = (await unavailableToolNames()).filter((id) => new RegExp(`\\b${id}\\b`).test(text));
64
- const fromBody = [...mentionedToolNames(text)].filter((id) => !registered.has(id));
65
- const missing = [...new Set([...fromCatalog, ...fromBody])].sort();
66
-
31
+ const missing = (await unavailableToolNames())
32
+ .filter((id) => new RegExp(`\\b${id}\\b`).test(text))
33
+ .sort();
67
34
  if (missing.length > 0) {
68
35
  logger.error('[skillService] Skill body calls tools that are not registered — the model cannot follow this body', {
69
36
  skill: name, missing,
@@ -153,6 +153,7 @@ async function handler(args, context = {}) {
153
153
  trace,
154
154
  messageRaw: intakeSummary,
155
155
  source: intakeSource,
156
+ isLive: runtime.isLive?.bind(runtime),
156
157
  });
157
158
  }
158
159
 
@@ -107,11 +107,18 @@ async function handler(args = {}, context = {}) {
107
107
  };
108
108
  trace.setSignals({ reminderIntents: [...existing, intent] });
109
109
 
110
+ const isLive = runtime.isLive?.bind(runtime);
111
+ const superseded = Boolean(isLive) && !isLive();
112
+
110
113
  if (runtime.patientCode) {
111
- try {
112
- await dispatchReminder({ intent, patientId: runtime.patientCode });
113
- } catch (err) {
114
- logger.warn('[schedulePatientReminder] reminder FHIR projection failed; ack unaffected', { turnId, error: err?.message });
114
+ if (superseded) {
115
+ logger.info('[schedulePatientReminder] reminder projection skipped — turn superseded', { turnId });
116
+ } else {
117
+ try {
118
+ await dispatchReminder({ intent, patientId: runtime.patientCode });
119
+ } catch (err) {
120
+ logger.warn('[schedulePatientReminder] reminder FHIR projection failed; ack unaffected', { turnId, error: err?.message });
121
+ }
115
122
  }
116
123
  }
117
124
 
@@ -124,6 +131,7 @@ async function handler(args = {}, context = {}) {
124
131
  reminderDateUtc: fireMoment.toISOString(),
125
132
  reminderType,
126
133
  description: storedDescription,
134
+ isLive,
127
135
  });
128
136
  }
129
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.11.0-dev.1131",
3
+ "version": "5.11.0-dev.1132",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",