@peopl-health/nexus 5.11.0-dev.1125 → 5.11.0-dev.1127

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, clearBridgeState } = require('./services/bridgeService');
11
+ const { maybeSendBridge } = require('./services/bridgeService');
12
12
  const { getBridgePresetId } = require('./flags/bridgeConfig');
13
13
  const { ToolRuntimeContext } = require('./context/ToolRuntimeContext');
14
14
 
@@ -136,7 +136,6 @@ class AssistantProcessor {
136
136
  if (!this.sendMessage) throw new Error('sendMessage function not configured');
137
137
  if (!result?.output) return null;
138
138
  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 });
139
- clearBridgeState(code);
140
139
  return result.output;
141
140
  }
142
141
  }
@@ -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, isSuperseded } = require('./BaseLLMProvider');
14
+ const { BaseLLMProvider, CONVERSATION_CONTINUATION, DELIVERY_NUDGE, MAX_DELIVERY_NUDGES, TERMINAL_DELIVERY_TOOLS } = 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, shouldContinue = null
72
+ resolvedPromptId = null, resolvedPresetId = 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?.dedupNote) bridgeNote = decision.dedupNote;
173
+ if (decision?.sent && decision.dedupNote) bridgeNote = decision.dedupNote;
174
174
  } catch (err) {
175
175
  logger.warn('[AnthropicProvider] bridge callback failed', { error: err.message });
176
176
  }
@@ -187,10 +187,6 @@ 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
- }
194
190
  const content = finalResponse.content || [];
195
191
  const toolUses = content.filter(block => block.type === 'tool_use');
196
192
  const ended = finalResponse.stop_reason !== 'tool_use' || !toolUses.length;
@@ -255,9 +251,9 @@ class AnthropicProvider extends BaseLLMProvider {
255
251
  }
256
252
 
257
253
  // Budget exhausted mid-work: the loop stopped while the model still wanted tools, so the reply may be empty. Make it
258
- // visible — but not on a supersede or delivery break, which also exit with pending calls and are not exhaustion.
254
+ // visible — but not on a delivery break, which also exits with pending calls and is not exhaustion.
259
255
  const pendingTools = (finalResponse.content || []).filter(block => block.type === 'tool_use');
260
- if (finalResponse.stop_reason === 'tool_use' && pendingTools.length && !deliveryBreak && !isSuperseded(shouldContinue)) {
256
+ if (finalResponse.stop_reason === 'tool_use' && pendingTools.length && !deliveryBreak) {
261
257
  logger.warn('[AnthropicProvider] Tool-iteration budget exhausted with pending tool calls', { maxFunctionRounds: this.maxFunctionRounds, pending: pendingTools.map(b => b.name) });
262
258
  trace?.setSignals?.({ toolBudgetExhausted: true });
263
259
  }
@@ -18,10 +18,6 @@ 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
-
25
21
  class BaseLLMProvider {
26
22
  constructor(options = {}) {
27
23
  if (new.target === BaseLLMProvider) {
@@ -336,8 +332,8 @@ class BaseLLMProvider {
336
332
  return result;
337
333
  }
338
334
 
339
- if (isSuperseded(config.shouldContinue)) {
340
- logger.info('[runConversation] Superseded — returning without retry', { attempt, shadow: config.shadow });
335
+ if (config.shadow && !config.shouldContinue()) {
336
+ logger.info('[runConversation] Shadow superseded — returning without retry', { attempt });
341
337
  return result;
342
338
  }
343
339
 
@@ -348,8 +344,8 @@ class BaseLLMProvider {
348
344
  await new Promise(r => setTimeout(r, 500));
349
345
  } catch (error) {
350
346
  logger.error('[runConversation] Attempt failed', { attempt, error: error.message });
351
- if (isSuperseded(config.shouldContinue)) {
352
- logger.info('[runConversation] Superseded — abandoning after error', { attempt, shadow: config.shadow });
347
+ if (config.shadow && !config.shouldContinue()) {
348
+ logger.info('[runConversation] Shadow superseded — abandoning after error', { attempt });
353
349
  return { status: 'cancelled', output_text: '', tools_executed: [], retries: 0 };
354
350
  }
355
351
  if (attempt === maxRetries) throw error;
@@ -532,5 +528,4 @@ module.exports = {
532
528
  DELIVERY_NUDGE,
533
529
  MAX_DELIVERY_NUDGES,
534
530
  TERMINAL_DELIVERY_TOOLS,
535
- isSuperseded,
536
531
  };
@@ -9,7 +9,7 @@ const { logger } = require('../../utils/logger');
9
9
 
10
10
  const runtimeConfig = require('../../config/runtimeConfig');
11
11
 
12
- const { BaseLLMProvider, isSuperseded } = require('./BaseLLMProvider');
12
+ const { BaseLLMProvider } = 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 superseded = () => isSuperseded(shouldContinue);
72
+ const shadowSuperseded = () => shadow && !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?.dedupNote) bridgeNote = decision.dedupNote;
130
+ if (decision?.sent && 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 (superseded()) {
144
- logger.info('[OpenAIResponsesProvider] turn superseded — aborting tool loop', { round, shadow });
143
+ if (shadowSuperseded()) {
144
+ logger.info('[OpenAIResponsesProvider] shadow superseded — aborting tool loop', { round });
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 supersede or delivery break, which also exit with pending calls and are not exhaustion.
170
+ // visible — but not on a shadow-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 && !superseded() && !deliveryBreak) {
172
+ if (pendingCalls.length && !shadowSuperseded() && !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,12 +1,10 @@
1
1
  const { OpenAI } = require('openai');
2
2
 
3
- const { logger } = require('../../utils/logger');
4
3
  const { retryWithBackoff } = require('../../utils/retryUtils');
5
4
  const { withTiming } = require('../../utils/tracingDecorator');
6
5
  const { safeParse } = require('../../utils/jsonUtils');
7
6
 
8
7
  const { OpenAIResponsesProvider } = require('./OpenAIResponsesProvider');
9
- const { isSuperseded } = require('./BaseLLMProvider');
10
8
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
11
9
 
12
10
  const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
@@ -39,7 +37,7 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
39
37
 
40
38
  async _invokeModel({
41
39
  instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
42
- modelConfig, assistant, phiProcessor = null, trace = null, shouldContinue = null
40
+ modelConfig, assistant, phiProcessor = null, trace = null
43
41
  }, options = {}) {
44
42
  const executeTools = options.toolExecutor || handleFunctionCalls;
45
43
  let totalRetries = 0;
@@ -76,10 +74,6 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
76
74
  apiCallConfig.tool_choice = followUpToolChoice;
77
75
 
78
76
  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
- }
83
77
  const message = finalResponse.choices?.[0]?.message;
84
78
  const toolCalls = message?.tool_calls || [];
85
79
  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, extra = {}) => {
28
+ const skip = (reason) => {
29
29
  logger.info('[bridge] skipped', { code, reason });
30
- return { sent: false, ...extra };
30
+ return { sent: false };
31
31
  };
32
32
 
33
33
  if (typeof shouldContinue === 'function' && !shouldContinue()) return skip('superseded');
@@ -36,35 +36,20 @@ 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
- const pending = bridgeTimestamps.get(code);
40
- if (pending !== undefined) {
41
- return skip('recent_bridge_in_thread', pending.delivered ? { dedupNote: pending.dedupNote } : {});
42
- }
39
+ if (bridgeTimestamps.get(code) !== undefined) return skip('recent_bridge_in_thread');
43
40
 
44
- const dedupNote = buildDedupNote(text);
45
- const entry = { dedupNote, delivered: 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
- };
41
+ bridgeTimestamps.set(code, true);
51
42
  try {
52
43
  Promise.resolve(sendMessage({ code, body: text, processed: true, origin: 'assistant', raw: { bridge: true }, turnId, traceId }))
53
- .then(() => { entry.delivered = true; })
54
- .catch(failed);
44
+ .catch(err => logger.warn('[bridge] send failed', { code, error: err.message }));
55
45
  } catch (err) {
56
- failed(err);
46
+ logger.warn('[bridge] send failed', { code, error: err.message });
57
47
  }
58
48
 
59
49
  logger.info('[bridge] sent', { code });
60
- return { sent: true, text, dedupNote };
61
- }
62
-
63
- function clearBridgeState(code) {
64
- bridgeTimestamps.delete(code);
50
+ return { sent: true, text, dedupNote: buildDedupNote(text) };
65
51
  }
66
52
 
67
53
  module.exports = {
68
54
  maybeSendBridge,
69
- clearBridgeState,
70
55
  };
@@ -11,26 +11,59 @@ 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]/;
14
17
 
15
18
  const cache = new MapCache({ maxSize: 2, ttl: CACHE_TTL });
16
19
 
17
- async function unavailableToolNames() {
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);
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;
23
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
+ async function unavailableToolNames() {
42
+ const catalog = await readToolCatalog();
43
+ if (!catalog) return [];
24
44
  const registered = new Set(getRegisteredToolNames());
25
45
  return catalog.filter((id) => !registered.has(id));
26
46
  }
27
47
 
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
+
28
57
  async function warnOnRegistryDrift(name, body) {
29
- if (getRegisteredToolNames().length === 0) return;
58
+ const registeredNames = getRegisteredToolNames();
59
+ if (registeredNames.length === 0) return;
60
+ const registered = new Set(registeredNames);
30
61
  const text = String(body || '');
31
- const missing = (await unavailableToolNames())
32
- .filter((id) => new RegExp(`\\b${id}\\b`).test(text))
33
- .sort();
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
+
34
67
  if (missing.length > 0) {
35
68
  logger.error('[skillService] Skill body calls tools that are not registered — the model cannot follow this body', {
36
69
  skill: name, missing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.11.0-dev.1125",
3
+ "version": "5.11.0-dev.1127",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",