@peopl-health/nexus 5.9.0-dev.921 → 6.0.0-dev.611

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/lib/clinical/AssistantProcessor.js +5 -34
  2. package/lib/clinical/config/divergenceConfig.js +1 -18
  3. package/lib/clinical/config/llmConfig.js +1 -9
  4. package/lib/clinical/context/ToolRuntimeContext.js +2 -1
  5. package/lib/clinical/helpers/divergenceHelper.js +0 -12
  6. package/lib/clinical/index.js +0 -2
  7. package/lib/clinical/memory/DefaultMemoryManager.js +9 -19
  8. package/lib/clinical/models/turnTraceModel.js +5 -18
  9. package/lib/clinical/providers/AnthropicProvider.js +29 -291
  10. package/lib/clinical/providers/BaseLLMProvider.js +24 -113
  11. package/lib/clinical/providers/GatewayProvider.js +3 -3
  12. package/lib/clinical/providers/OpenAIResponsesProvider.js +4 -46
  13. package/lib/clinical/services/divergenceService.js +5 -22
  14. package/lib/clinical/services/promptComposerService.js +15 -46
  15. package/lib/clinical/services/shadowMessageService.js +12 -28
  16. package/lib/clinical/services/shadowService.js +3 -12
  17. package/lib/clinical/stores/CtcaeCatalog.js +0 -20
  18. package/lib/config/airtableConfig.js +0 -3
  19. package/lib/core/BatchingManager.js +3 -10
  20. package/lib/core/NexusMessaging.js +13 -31
  21. package/lib/eval/EvalProvider.js +2 -3
  22. package/lib/fhir/config/fhirConfig.js +3 -34
  23. package/lib/fhir/constants/projectionSlugs.js +1 -41
  24. package/lib/fhir/helpers/elementHelper.js +0 -21
  25. package/lib/fhir/helpers/fhirHelper.js +2 -5
  26. package/lib/fhir/index.js +0 -24
  27. package/lib/fhir/models/envelopeModel.js +0 -5
  28. package/lib/fhir/projections/registerProjectors.js +0 -16
  29. package/lib/fhir/resources/Condition.js +2 -107
  30. package/lib/fhir/resources/Observation.js +1 -73
  31. package/lib/fhir/resources/Procedure.js +2 -34
  32. package/lib/fhir/resources/Provenance.js +2 -93
  33. package/lib/fhir/services/fhirService.js +2 -2
  34. package/lib/helpers/twilioHelper.js +1 -9
  35. package/lib/index.d.ts +0 -16
  36. package/lib/index.js +0 -9
  37. package/lib/models/messageModel.js +0 -6
  38. package/lib/routes/index.js +1 -21
  39. package/lib/services/airtableService.js +1 -2
  40. package/lib/shared/dtos/ContingencySafetyNet.js +3 -10
  41. package/lib/shared/dtos/DispatchedEscalation.js +8 -3
  42. package/lib/shared/dtos/Reminder.js +2 -2
  43. package/lib/storage/MongoStorage.js +0 -2
  44. package/package.json +1 -1
  45. package/lib/clinical/config/cohortConfig.js +0 -68
  46. package/lib/clinical/config/composerConfig.js +0 -42
  47. package/lib/clinical/config/subAgentsConfig.js +0 -44
  48. package/lib/clinical/flags/bridgeConfig.js +0 -14
  49. package/lib/clinical/flags/composerFlags.js +0 -14
  50. package/lib/clinical/helpers/clinicalFactsHelper.js +0 -108
  51. package/lib/clinical/helpers/clinicalMentionHelper.js +0 -68
  52. package/lib/clinical/helpers/cohortHelper.js +0 -22
  53. package/lib/clinical/helpers/gradeEstimateHelper.js +0 -29
  54. package/lib/clinical/helpers/resultsHelper.js +0 -34
  55. package/lib/clinical/helpers/safetyFlagsHelper.js +0 -10
  56. package/lib/clinical/services/bridgeService.js +0 -55
  57. package/lib/clinical/services/clinicalAirtableService.js +0 -162
  58. package/lib/clinical/services/clinicalExtractionService.js +0 -112
  59. package/lib/clinical/services/composerService.js +0 -59
  60. package/lib/clinical/services/dispatcherService.js +0 -137
  61. package/lib/clinical/services/reminderDispatchService.js +0 -36
  62. package/lib/clinical/services/skillService.js +0 -44
  63. package/lib/clinical/tools/analyzeSymptomPatternsTool.js +0 -279
  64. package/lib/clinical/tools/deliverPatientMessageTool.js +0 -202
  65. package/lib/clinical/tools/extractClinicalInfoTool.js +0 -167
  66. package/lib/clinical/tools/getActiveSymptomLandscapeTool.js +0 -141
  67. package/lib/clinical/tools/getPatientHistoryTool.js +0 -235
  68. package/lib/clinical/tools/getPatientRiskProfileTool.js +0 -71
  69. package/lib/clinical/tools/getRouterContextBundleTool.js +0 -93
  70. package/lib/clinical/tools/lookupCtcaeEntryTool.js +0 -35
  71. package/lib/clinical/tools/openClusterHypothesisTool.js +0 -160
  72. package/lib/clinical/tools/openConditionTool.js +0 -136
  73. package/lib/clinical/tools/recordClinicalImpressionTool.js +0 -251
  74. package/lib/clinical/tools/recordInterventionTool.js +0 -140
  75. package/lib/clinical/tools/registerClinicalTools.js +0 -39
  76. package/lib/clinical/tools/reportCrisisTool.js +0 -211
  77. package/lib/clinical/tools/reportMedicalEscalationTool.js +0 -187
  78. package/lib/clinical/tools/reportResultsReceivedTool.js +0 -153
  79. package/lib/clinical/tools/reportUnresolvedRequestv2Tool.js +0 -177
  80. package/lib/clinical/tools/schedulePatientReminderTool.js +0 -149
  81. package/lib/clinical/tools/setContingencyPlanTool.js +0 -176
  82. package/lib/clinical/tools/skillTool.js +0 -67
  83. package/lib/clinical/tools/submitRoutingDecisionTool.js +0 -291
  84. package/lib/clinical/tools/updateClusterHypothesisTool.js +0 -159
  85. package/lib/controllers/cohortController.js +0 -16
  86. package/lib/controllers/traceController.js +0 -26
  87. package/lib/core/workflowRunner.js +0 -35
  88. package/lib/fhir/config/fhirTablesConfig.js +0 -9
  89. package/lib/fhir/constants/severityPriority.js +0 -8
  90. package/lib/fhir/helpers/compositeHelper.js +0 -88
  91. package/lib/fhir/helpers/fhirReadHelper.js +0 -42
  92. package/lib/fhir/helpers/terminologyHelper.js +0 -260
  93. package/lib/fhir/projections/clusterProjection.js +0 -24
  94. package/lib/fhir/projections/contingencyProjection.js +0 -36
  95. package/lib/fhir/projections/dispatchedEscalationProjection.js +0 -25
  96. package/lib/fhir/projections/interventionProjection.js +0 -42
  97. package/lib/fhir/projections/proctcaeProjection.js +0 -179
  98. package/lib/fhir/projections/reminderProjection.js +0 -20
  99. package/lib/fhir/projections/riskProjection.js +0 -16
  100. package/lib/fhir/projections/routingProjection.js +0 -48
  101. package/lib/fhir/projections/symptomCaseProjection.js +0 -65
  102. package/lib/fhir/resources/CarePlan.js +0 -93
  103. package/lib/fhir/resources/ClinicalImpression.js +0 -118
  104. package/lib/fhir/resources/Communication.js +0 -65
  105. package/lib/fhir/resources/CommunicationRequest.js +0 -182
  106. package/lib/fhir/resources/Device.js +0 -24
  107. package/lib/fhir/resources/Patient.js +0 -18
  108. package/lib/fhir/resources/Questionnaire.js +0 -23
  109. package/lib/fhir/resources/QuestionnaireResponse.js +0 -29
  110. package/lib/fhir/resources/RequestOrchestration.js +0 -46
  111. package/lib/fhir/resources/RiskAssessment.js +0 -99
  112. package/lib/fhir/resources/Task.js +0 -106
  113. package/lib/fhir/resources/interventionExtensions.js +0 -28
  114. package/lib/fhir/services/clusterService.js +0 -77
  115. package/lib/fhir/services/contingencyService.js +0 -117
  116. package/lib/fhir/services/interventionService.js +0 -149
  117. package/lib/fhir/services/resultObservationService.js +0 -52
  118. package/lib/fhir/services/riskService.js +0 -101
  119. package/lib/fhir/services/routingService.js +0 -147
  120. package/lib/fhir/services/snapshotService.js +0 -28
  121. package/lib/fhir/services/symptomCaseService.js +0 -139
  122. package/lib/fhir/services/terminologyService.js +0 -58
  123. package/lib/fhir/services/triageService.js +0 -57
  124. package/lib/fhir/utils/normalizeUtils.js +0 -33
  125. package/lib/shared/dtos/GradedSymptom.js +0 -23
  126. package/lib/shared/dtos/TriageResponse.js +0 -35
@@ -1,6 +1,5 @@
1
1
  const { DefaultMemoryManager } = require('../memory/DefaultMemoryManager');
2
2
  const { composePrompt, resolveTools } = require('../services/promptComposerService');
3
- const { resolveEnabledSkills } = require('../services/skillService');
4
3
  const runtimeConfig = require('../../config/runtimeConfig');
5
4
  const { logger } = require('../../utils/logger');
6
5
  const { getCurrentMexicoDateTime } = require('../../utils/dateUtils');
@@ -13,10 +12,6 @@ const { logBugReportToAirtable } = require('../../controllers/bugReportControlle
13
12
 
14
13
  const DEFAULT_MAX_CONVERSATION_RETRIES = 3;
15
14
  const CONVERSATION_CONTINUATION = '(continuar)';
16
- const NEGATIVE_GUIDANCE_MARKER = '**When NOT to call';
17
- const TERMINAL_DELIVERY_TOOLS = new Set(['DeliverPatientMessage']);
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
- const MAX_DELIVERY_NUDGES = 1;
20
15
 
21
16
  class BaseLLMProvider {
22
17
  constructor(options = {}) {
@@ -41,13 +36,12 @@ class BaseLLMProvider {
41
36
  instructions = null, additionalInstructions = null, metadata = {},
42
37
  assistant, promptVersion = null, promptVariables = null,
43
38
  toolChoice = 'auto', prePromptResult = null, phiProcessor = null,
44
- shadow = false, shouldContinue = null, onFirstIteration = null
39
+ shadow = false, shouldContinue = null
45
40
  } = config;
46
41
  const trace = assistant?.toolRuntimeContext?.trace || null;
47
42
 
48
43
  const {
49
- resolvedPrompt, interpolatedVariables, snippetIds, promptId: resolvedPromptId,
50
- presetToolIds, presetSkillIds,
44
+ resolvedPrompt, snippetIds, promptId: resolvedPromptId, presetToolIds,
51
45
  presetId: resolvedPresetId, presetVersion: resolvedPresetVersion, modelConfig,
52
46
  } = await composePrompt({ presetId, presetVersion, promptId: assistantId, variables: promptVariables });
53
47
 
@@ -55,18 +49,9 @@ class BaseLLMProvider {
55
49
  promptId: resolvedPromptId || assistantId, assistant, presetToolIds,
56
50
  });
57
51
 
58
- if (assistant?.toolRuntimeContext) {
59
- assistant.toolRuntimeContext.enabledSkills = await resolveEnabledSkills(presetSkillIds);
60
- }
61
-
62
- const resolvedSchemas = this._resolveToolSchemas({ assistant, toolIds, filtered, toolDescriptions });
63
- const { toolChoice: flooredToolChoice, toolSchemas, narrowed } = this._applyDeliveryFloor(toolChoice, resolvedSchemas, prePromptResult?.toolChoice);
64
- const effectiveToolIds = narrowed ? toolSchemas.map(s => s.function?.name).filter(Boolean) : toolIds;
65
- const devContent = this._buildInstructions({ resolvedPrompt, toolSchemas, prePromptResult });
66
- const auditedPrompt = additionalInstructions
67
- ? `${devContent}\n\n${this._operatorInstructionTurn(additionalInstructions, modelConfig).content}`
68
- : devContent;
69
- const input = this._buildInput({ context, prePromptResult, additionalInstructions, promptVariables, modelConfig, interpolatedVariables });
52
+ const toolSchemas = this._resolveToolSchemas({ assistant, toolIds, filtered, toolDescriptions });
53
+ const devContent = this._buildInstructions({ resolvedPrompt, toolSchemas, prePromptResult, additionalInstructions });
54
+ const input = this._buildInput({ context, prePromptResult, additionalInstructions, promptVariables, modelConfig });
70
55
 
71
56
  // Capture before _invokeModel: the divergence oracle replays this shadow request shape.
72
57
  if (shadow && assistant?.toolRuntimeContext) {
@@ -79,19 +64,19 @@ class BaseLLMProvider {
79
64
  },
80
65
  input,
81
66
  model: modelConfig?.model || null,
82
- toolIds: Array.isArray(effectiveToolIds) ? effectiveToolIds : [],
67
+ toolIds: Array.isArray(toolIds) ? toolIds : [],
83
68
  additionalInstructions: additionalInstructions || null,
84
69
  };
85
70
  }
86
71
 
87
- trace?.setPrompt({ resolved: auditedPrompt, resolvedPresetId, resolvedPresetVersion });
72
+ trace?.setPrompt({ resolved: devContent, resolvedPresetId, resolvedPresetVersion });
88
73
 
89
74
  const { response, toolsExecuted, retries, usage } = await this._invokeModel({
90
75
  instructions: instructions || devContent || '',
91
76
  input,
92
77
  toolSchemas,
93
- toolChoice: prePromptResult?.toolChoice || flooredToolChoice,
94
- followUpToolChoice: flooredToolChoice,
78
+ toolChoice: prePromptResult?.toolChoice || toolChoice,
79
+ followUpToolChoice: toolChoice,
95
80
  modelConfig,
96
81
  metadata,
97
82
  assistant,
@@ -103,7 +88,6 @@ class BaseLLMProvider {
103
88
  trace,
104
89
  shadow,
105
90
  shouldContinue,
106
- onFirstIteration,
107
91
  });
108
92
 
109
93
  return {
@@ -114,9 +98,9 @@ class BaseLLMProvider {
114
98
  tools_executed: toolsExecuted,
115
99
  retries,
116
100
  usage,
117
- resolved_prompt: auditedPrompt,
101
+ resolved_prompt: devContent,
118
102
  snippet_ids: snippetIds,
119
- tool_ids: effectiveToolIds,
103
+ tool_ids: toolIds,
120
104
  preset_id: resolvedPresetId,
121
105
  preset_version: resolvedPresetVersion,
122
106
  preset: resolvedPresetId ? { id: resolvedPresetId, version: resolvedPresetVersion } : null,
@@ -140,17 +124,13 @@ class BaseLLMProvider {
140
124
  return activeToolSchemas.map(s => {
141
125
  const name = s.function?.name;
142
126
  if (name && toolDescriptions[name]) {
143
- if (s.function.description?.includes(NEGATIVE_GUIDANCE_MARKER)
144
- && !toolDescriptions[name].includes(NEGATIVE_GUIDANCE_MARKER)) {
145
- logger.warn('[BaseLLMProvider] Airtable description drops the when-NOT-to-call guidance written in code', { tool: name });
146
- }
147
127
  return { ...s, function: { ...s.function, description: toolDescriptions[name] } };
148
128
  }
149
129
  return s;
150
130
  });
151
131
  }
152
132
 
153
- _buildInstructions({ resolvedPrompt, toolSchemas, prePromptResult }) {
133
+ _buildInstructions({ resolvedPrompt, toolSchemas, prePromptResult, additionalInstructions }) {
154
134
  let devContent = resolvedPrompt;
155
135
  if (toolSchemas.length > 0) {
156
136
  const toolNames = toolSchemas.map(s => s.function?.name).join(', ');
@@ -159,14 +139,21 @@ class BaseLLMProvider {
159
139
  if (prePromptResult?.additionalInstructions) {
160
140
  devContent += `\n\n${prePromptResult.additionalInstructions}`;
161
141
  }
142
+ if (additionalInstructions) {
143
+ devContent += this._operatorInstructionSuffix(additionalInstructions);
144
+ }
162
145
  return devContent;
163
146
  }
164
147
 
165
- _operatorInstructionTurn(additionalInstructions) {
166
- return { role: 'developer', content: `[DIRECTIVA DEL OPERADOR (prioritaria) para tu próxima respuesta al paciente]: ${additionalInstructions}` };
148
+ _operatorInstructionSuffix(additionalInstructions) {
149
+ return `\n\n[DIRECTIVA DEL OPERADOR (prioritaria) para tu próxima respuesta al paciente]: ${additionalInstructions}`;
150
+ }
151
+
152
+ _operatorInstructionTurn() {
153
+ return { role: 'user', content: CONVERSATION_CONTINUATION };
167
154
  }
168
155
 
169
- _buildInput({ context, prePromptResult, additionalInstructions, promptVariables, modelConfig = null, interpolatedVariables = [] }) {
156
+ _buildInput({ context, prePromptResult, additionalInstructions, promptVariables, modelConfig = null }) {
170
157
  let messages = this._convertItemsToApiFormat(context || []);
171
158
  if (Array.isArray(prePromptResult?.additionalMessages) && prePromptResult.additionalMessages.length > 0) {
172
159
  messages = [...messages, ...this._convertItemsToApiFormat(prePromptResult.additionalMessages)];
@@ -174,35 +161,7 @@ class BaseLLMProvider {
174
161
  if (additionalInstructions) {
175
162
  messages = [...messages, this._operatorInstructionTurn(additionalInstructions, modelConfig)];
176
163
  }
177
- return this._sanitizeInput([
178
- ...this._buildMemoryMessage(promptVariables),
179
- ...this._buildClinicalMessage(promptVariables, interpolatedVariables),
180
- ...this._buildClockMessage(promptVariables),
181
- ...messages,
182
- ]);
183
- }
184
-
185
- _buildClinicalMessage(promptVariables, interpolatedVariables = []) {
186
- const inPrompt = new Set(interpolatedVariables);
187
- const context = this._clinicalText(promptVariables?.clinical_context, 'clinical_context');
188
- const symptoms = this._clinicalText(promptVariables?.last_symptoms, 'last_symptoms');
189
- const parts = [];
190
- if (context && !inPrompt.has('clinical_context')) {
191
- parts.push(`## Contexto clínico del paciente\n${context}`);
192
- }
193
- if (symptoms && !inPrompt.has('last_symptoms')) {
194
- parts.push(`## Síntomas recientes\n${symptoms}`);
195
- }
196
- if (parts.length === 0) return [];
197
- return [{ role: 'developer', content: parts.join('\n\n') }];
198
- }
199
-
200
- _clinicalText(value, field) {
201
- if (typeof value === 'string') return value.trim();
202
- if (value != null) {
203
- logger.warn('[BaseLLMProvider] Non-string clinical variable, omitting from runtime context', { field, type: typeof value });
204
- }
205
- return '';
164
+ return this._sanitizeInput([...this._buildMemoryMessage(promptVariables), ...messages]);
206
165
  }
207
166
 
208
167
  _buildMemoryMessage(promptVariables) {
@@ -213,14 +172,6 @@ class BaseLLMProvider {
213
172
  return [{ role: 'developer', content: `Usa la siguiente información que conoces del paciente para personalizar tu respuesta. Adapta tu tono, contenido o recomendaciones según estas preferencias y contexto cuando sea relevante. No menciones que tienes esta información almacenada.\n\n${memoryParts.join('\n\n')}` }];
214
173
  }
215
174
 
216
- // Delivers the clock after the breakpoint, so a minute-granular date stops rewriting the cached
217
- // prefix. Off until {{current_date}} leaves the prompt body, or the date would be sent twice.
218
- _buildClockMessage(promptVariables) {
219
- if (runtimeConfig.get('CLOCK_OUTSIDE_PROMPT') !== 'true') return [];
220
- if (!promptVariables?.current_date) return [];
221
- return [{ role: 'developer', content: `Fecha y hora actual: ${promptVariables.current_date}` }];
222
- }
223
-
224
175
  async _invokeModel() {
225
176
  throw new Error(`${this.constructor.name} must implement _invokeModel()`);
226
177
  }
@@ -316,9 +267,7 @@ class BaseLLMProvider {
316
267
  logger.info('[runConversation] Attempt', { attempt, maxRetries, threadId, assistantId });
317
268
 
318
269
  const result = await this._executeConversation(config);
319
- // DeliverPatientMessage is the curated patient reply; prefer it over any model narration in the final response.
320
- const delivered = this._extractDeliveredMessage(config, result);
321
- const extractedOutput = delivered?.trim() ? delivered : this._extractMessageOutput(result);
270
+ const extractedOutput = this._extractMessageOutput(result);
322
271
 
323
272
  if (extractedOutput?.trim()) {
324
273
  result.output_text = extractedOutput;
@@ -352,41 +301,6 @@ class BaseLLMProvider {
352
301
  }
353
302
  }
354
303
 
355
- _toolSucceeded(t) {
356
- // The executor sets success=true unless the call threw; a tool can also fail by returning {success:false} in its output.
357
- if (!t || t.success === false) return false;
358
- if (typeof t.tool_output === 'string') {
359
- try { if (JSON.parse(t.tool_output)?.success === false) return false; } catch { /* non-JSON output */ }
360
- }
361
- return true;
362
- }
363
-
364
- _deliveryCommitted(toolsExecuted) {
365
- return (toolsExecuted || []).some(t => TERMINAL_DELIVERY_TOOLS.has(t?.tool_name) && this._toolSucceeded(t));
366
- }
367
-
368
- // On a preset whose only patient-visible output path is a delivery tool, 'none' leaves the turn nothing it may
369
- // emit, so it retries on empty output until it throws. Read it as "only the delivery tool" instead — the
370
- // side-effect guarantee the caller wanted survives the narrowing.
371
- // Skipped when a pre-prompt hook forced a choice: that choice may name a tool the narrowing would drop.
372
- _applyDeliveryFloor(toolChoice, toolSchemas, prePromptToolChoice = null) {
373
- if (prePromptToolChoice) return { toolChoice, toolSchemas, narrowed: false };
374
- if (toolChoice !== 'none') return { toolChoice, toolSchemas, narrowed: false };
375
- const deliveryOnly = toolSchemas.filter(s => TERMINAL_DELIVERY_TOOLS.has(s.function?.name));
376
- if (!deliveryOnly.length) return { toolChoice, toolSchemas, narrowed: false };
377
- return { toolChoice: 'auto', toolSchemas: deliveryOnly, narrowed: true };
378
- }
379
-
380
- _extractDeliveredMessage(config, result) {
381
- const executed = Array.isArray(result?.tools_executed) ? result.tools_executed : [];
382
- // Only count a delivery from THIS run, so a stale/prior trace.outbound can't leak in.
383
- const delivered = executed.some(t => t?.tool_name === 'DeliverPatientMessage' && this._toolSucceeded(t));
384
- if (!delivered) return '';
385
- // trace.outbound is the decoded/rendered outbound the handler wrote; message_text (raw tool arg) may be PHI-tokenized, so it is not used.
386
- const traceOutbound = config?.assistant?.toolRuntimeContext?.trace?.outbound;
387
- return (typeof traceOutbound === 'string' && traceOutbound.trim()) ? traceOutbound : '';
388
- }
389
-
390
304
  _normalizeThread(thread) {
391
305
  return {
392
306
  conversationId: thread.conversation_id || thread.getConversationId?.(),
@@ -523,7 +437,4 @@ class BaseLLMProvider {
523
437
  module.exports = {
524
438
  BaseLLMProvider,
525
439
  CONVERSATION_CONTINUATION,
526
- DELIVERY_NUDGE,
527
- MAX_DELIVERY_NUDGES,
528
- TERMINAL_DELIVERY_TOOLS,
529
440
  };
@@ -74,10 +74,10 @@ class GatewayProvider extends BaseLLMProvider {
74
74
  model = composed.modelConfig?.model || null;
75
75
  }
76
76
  const { vendor, model: routedModel } = this._route(model);
77
- if (vendor !== 'openai' && vendor !== 'anthropic') {
78
- throw new Error(`runStructured is only supported for OpenAI and Anthropic models; routed model '${model}' resolved to '${vendor}'`);
77
+ if (vendor !== 'openai') {
78
+ throw new Error(`runStructured is only supported for OpenAI models; routed model '${model}' resolved to '${vendor}'`);
79
79
  }
80
- return this._sub(vendor).runStructured({ ...params, model: routedModel });
80
+ return this._sub('openai').runStructured({ ...params, model: routedModel });
81
81
  }
82
82
  }
83
83
 
@@ -7,23 +7,10 @@ const { safeParse } = require('../../utils/jsonUtils');
7
7
 
8
8
  const { logger } = require('../../utils/logger');
9
9
 
10
- const runtimeConfig = require('../../config/runtimeConfig');
11
-
12
10
  const { BaseLLMProvider } = require('./BaseLLMProvider');
13
11
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
14
12
 
15
- const DEFAULT_MAX_FUNCTION_ROUNDS = 12;
16
-
17
- function resolveMaxFunctionRounds(explicit) {
18
- if (Number.isInteger(explicit) && explicit > 0) return explicit;
19
- const raw = runtimeConfig.get('MAX_FUNCTION_ROUNDS');
20
- const n = parseInt(raw, 10);
21
- if (n > 0) return n;
22
- if (raw !== undefined) {
23
- logger.warn('[OpenAIResponsesProvider] Invalid MAX_FUNCTION_ROUNDS, falling back to default', { value: raw, default: DEFAULT_MAX_FUNCTION_ROUNDS });
24
- }
25
- return DEFAULT_MAX_FUNCTION_ROUNDS;
26
- }
13
+ const DEFAULT_MAX_FUNCTION_ROUNDS = 5;
27
14
 
28
15
  class OpenAIResponsesProvider extends BaseLLMProvider {
29
16
  constructor(options = {}) {
@@ -33,7 +20,7 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
33
20
  organization,
34
21
  client,
35
22
  defaultModels = {},
36
- maxFunctionRounds,
23
+ maxFunctionRounds = DEFAULT_MAX_FUNCTION_ROUNDS,
37
24
  } = options;
38
25
 
39
26
  if (!client && !apiKey) {
@@ -41,7 +28,7 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
41
28
  }
42
29
 
43
30
  this.client = client || new OpenAI({ apiKey, organization });
44
- this.maxFunctionRounds = resolveMaxFunctionRounds(maxFunctionRounds);
31
+ this.maxFunctionRounds = maxFunctionRounds;
45
32
  this.defaults = {
46
33
  responseModel: 'gpt-5',
47
34
  chatModel: 'gpt-4o-mini',
@@ -66,7 +53,7 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
66
53
  instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
67
54
  modelConfig, metadata = {}, assistant, phiProcessor = null,
68
55
  promptVariables = null, promptVersion = null, resolvedPromptId = null, resolvedPresetId = null, trace = null,
69
- shadow = false, shouldContinue = null, onFirstIteration = null
56
+ shadow = false, shouldContinue = null
70
57
  }, options = {}) {
71
58
  const executeTools = options.toolExecutor || handleFunctionCalls;
72
59
  const shadowSuperseded = () => shadow && !shouldContinue();
@@ -116,23 +103,6 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
116
103
  let producingIteration = trace?.addIteration({ response, durationMs, apiCallConfig }) || null;
117
104
  let finalResponse = response;
118
105
 
119
- let bridgeNote = null;
120
- if (onFirstIteration) {
121
- const narrationText = (response.output || [])
122
- .filter(item => item.type === 'message')
123
- .map(item => this._messageItemToText(item))
124
- .filter(Boolean)
125
- .join(' ')
126
- .trim();
127
- const hasToolCalls = Boolean(assistant) && (response.output || []).some(item => item.type === 'function_call');
128
- try {
129
- const decision = onFirstIteration({ narrationText, hasToolCalls });
130
- if (decision?.sent && decision.dedupNote) bridgeNote = decision.dedupNote;
131
- } catch (err) {
132
- logger.warn('[OpenAIResponsesProvider] bridge callback failed', { error: err.message });
133
- }
134
- }
135
-
136
106
  if (assistant && response.output) {
137
107
  let currentInput = [...input];
138
108
 
@@ -150,10 +120,6 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
150
120
  const { outputs, toolsExecuted } = await executeTools(functionCalls, assistant, phiProcessor, producingIteration);
151
121
 
152
122
  currentInput.push(...finalResponse.output, ...outputs);
153
- if (bridgeNote) {
154
- currentInput.push({ role: 'developer', content: bridgeNote });
155
- bridgeNote = null;
156
- }
157
123
  allToolsExecuted.push(...toolsExecuted);
158
124
 
159
125
  const { result: { result: followUp, retries: followUpRetries }, durationMs: followUpDurationMs } = await withTiming(() => makeAPICall(currentInput));
@@ -162,14 +128,6 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
162
128
  producingIteration = trace?.addIteration({ response: followUp, durationMs: followUpDurationMs, apiCallConfig }) || null;
163
129
  finalResponse = followUp;
164
130
  }
165
-
166
- // Budget exhausted mid-work: the loop stopped while the model still wanted tools, so the reply may be empty. Make it
167
- // visible — but not on a shadow-supersede break (:142), which also exits with pending calls and is not exhaustion.
168
- const pendingCalls = (finalResponse.output || []).filter(item => item.type === 'function_call');
169
- if (pendingCalls.length && !shadowSuperseded()) {
170
- logger.warn('[OpenAIResponsesProvider] Tool-iteration budget exhausted with pending tool calls', { maxFunctionRounds: this.maxFunctionRounds, pending: pendingCalls.map(c => c.name) });
171
- trace?.setSignals?.({ toolBudgetExhausted: true });
172
- }
173
131
  }
174
132
 
175
133
  return { response: finalResponse, toolCalls: allToolCalls, toolsExecuted: allToolsExecuted, retries: totalRetries, usage: accumulatedUsage };
@@ -1,6 +1,6 @@
1
1
  const divergenceConfig = require('../config/divergenceConfig');
2
2
  const { getDivergenceModel } = require('../models/divergenceModel');
3
- const { classify, oracleToolsInvoked } = require('../helpers/divergenceHelper');
3
+ const { classify } = require('../helpers/divergenceHelper');
4
4
  const runtimeConfig = require('../../config/runtimeConfig');
5
5
  const { logger } = require('../../utils/logger');
6
6
 
@@ -8,19 +8,15 @@ const { runClinicalAgent } = require('./clinicalAgentService');
8
8
 
9
9
  async function runDivergence({ request, connection, trace, actualMessage }) {
10
10
  let post;
11
- const startedAt = Date.now();
12
11
  try {
13
12
  post = await runClinicalAgent(request, connection);
14
- logger.debug('[divergence] oracle responded', { runId: request.runId, status: post.status, durationMs: Date.now() - startedAt });
15
13
  if (post.status !== 200) {
16
14
  return { ok: false, status: post.status, response: post.body };
17
15
  }
18
16
  } catch (error) {
19
- logger.debug('[divergence] oracle errored', { runId: request.runId, error: error.message, durationMs: Date.now() - startedAt });
20
17
  return { ok: false, status: 503, response: { error: error.message } };
21
18
  }
22
- const expectedTurn = post.body?.run?.sessions?.[0]?.turns?.[0] || {};
23
- const expected = { ...expectedTurn, tools_invoked: oracleToolsInvoked(expectedTurn, request.enabledTools) };
19
+ const expected = post.body?.run?.sessions?.[0]?.turns?.[0] || {};
24
20
  const expectedMessage = post.body?.response || '';
25
21
 
26
22
  const actual = trace.toDecision();
@@ -34,22 +30,12 @@ async function runDivergence({ request, connection, trace, actualMessage }) {
34
30
  }
35
31
 
36
32
  async function diverge({ shadow, runId }) {
37
- const code = shadow?.context?.patientCode;
38
33
  const inputs = shadow?.context?.divergenceInputs;
39
- if (!inputs) {
40
- logger.debug('[divergence] skip: no inputs', { runId, code });
41
- return null;
42
- }
43
- if (!shadow.result?.completed) {
44
- logger.debug('[divergence] skip: shadow incomplete', { runId, code });
45
- return null;
46
- }
34
+ if (!inputs) return null;
35
+ if (!shadow.result?.completed) return null;
47
36
 
48
37
  const { enabled, baseUrl, timeoutMs } = await divergenceConfig.getOracleConnection();
49
- if (!enabled || !baseUrl) {
50
- logger.debug('[divergence] skip: oracle off', { runId, code });
51
- return null;
52
- }
38
+ if (!enabled || !baseUrl) return null;
53
39
 
54
40
  const request = {
55
41
  runId,
@@ -59,7 +45,6 @@ async function diverge({ shadow, runId }) {
59
45
  enabledTools: inputs.toolIds || [],
60
46
  additionalInstructions: inputs.additionalInstructions || null,
61
47
  };
62
- logger.debug('[divergence] oracle request', { runId, code, model: request.model, inputLen: request.input.length, tools: request.enabledTools.length });
63
48
 
64
49
  const connection = {
65
50
  baseUrl,
@@ -72,10 +57,8 @@ async function diverge({ shadow, runId }) {
72
57
  });
73
58
 
74
59
  if (report.ok) {
75
- logger.debug('[divergence] classified', { runId, code, result: report.result, emergency: !!report.isEmergencyReview });
76
60
  try {
77
61
  await getDivergenceModel().record({ shadow, request, report });
78
- logger.debug('[divergence] persisted', { runId, code });
79
62
  } catch (error) {
80
63
  logger.warn('[divergence] persist failed', { runId, error: error.message });
81
64
  }
@@ -15,15 +15,6 @@ const snippetCache = new MapCache({ maxSize: 200, ttl: CACHE_TTL });
15
15
  const toolCache = new MapCache({ maxSize: 50, ttl: CACHE_TTL });
16
16
  const presetCache = new MapCache({ maxSize: 50, ttl: CACHE_TTL });
17
17
 
18
- async function fetchConfigRecords(table, filter) {
19
- const records = await getRecordByFilter(Config_ID, table, filter);
20
- if (!records) {
21
- logger.error('[promptComposer] Airtable read failed — refusing to widen the toolset', { table, filter });
22
- throw new Error(`[promptComposer] Airtable read failed for ${table}`);
23
- }
24
- return records;
25
- }
26
-
27
18
  async function fetchPreset(presetId, version = null) {
28
19
  const cacheKey = version ? `preset:${presetId}:v${version}` : `preset:${presetId}:active`;
29
20
  const cached = presetCache.get(cacheKey);
@@ -33,13 +24,8 @@ async function fetchPreset(presetId, version = null) {
33
24
  ? `AND({preset_id} = "${presetId}", {version} = ${version})`
34
25
  : `AND({preset_id} = "${presetId}", {is_active} = 1)`;
35
26
 
36
- const records = await fetchConfigRecords('presets', filter);
37
- if (records.length > 1) {
38
- logger.error('[promptComposer] Ambiguous preset — several records match; using the first', {
39
- presetId, version, matched: records.length,
40
- });
41
- }
42
- const record = records[0] || null;
27
+ const records = await getRecordByFilter(Config_ID, 'presets', filter);
28
+ const record = records?.[0] || null;
43
29
  if (record) presetCache.set(cacheKey, record);
44
30
  return record;
45
31
  }
@@ -68,10 +54,6 @@ function applyVariables(text, variables) {
68
54
  return text.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? '');
69
55
  }
70
56
 
71
- function variablesReferencedIn(text) {
72
- return [...new Set([...(text || '').matchAll(/\{\{(\w+)\}\}/g)].map(m => m[1]))];
73
- }
74
-
75
57
  async function fetchSnippetsByRecordIds(recordIds) {
76
58
  if (!recordIds?.length) return [];
77
59
 
@@ -95,7 +77,6 @@ async function composePrompt({ presetId = null, presetVersion = null, promptId =
95
77
  let resolvedPromptId = promptId;
96
78
  let presetSnippetIds = null;
97
79
  let presetToolIds = null;
98
- let presetSkillIds = null;
99
80
  let resolvedPresetVersion = null;
100
81
  let modelConfig = null;
101
82
 
@@ -103,12 +84,10 @@ async function composePrompt({ presetId = null, presetVersion = null, promptId =
103
84
  const preset = await fetchPreset(id, version);
104
85
  if (!preset) {
105
86
  logger.error('[promptComposer] Preset not found in Airtable', { presetId: id, presetVersion: version });
106
- presetToolIds = [];
107
87
  return;
108
88
  }
109
89
  presetSnippetIds = preset.snippets || [];
110
90
  presetToolIds = preset.tools || [];
111
- presetSkillIds = preset.skills || [];
112
91
  resolvedPresetVersion = preset.version ? String(preset.version) : null;
113
92
 
114
93
  if (preset.model_config) {
@@ -153,7 +132,6 @@ async function composePrompt({ presetId = null, presetVersion = null, promptId =
153
132
  baseContent = [...sorted.map(s => s.content), baseContent].join('\n\n');
154
133
  }
155
134
 
156
- const interpolatedVariables = variablesReferencedIn(baseContent);
157
135
  const resolvedPrompt = applyVariables(baseContent, variables);
158
136
 
159
137
  logger.debug('[promptComposer] Prompt composed', {
@@ -167,11 +145,9 @@ async function composePrompt({ presetId = null, presetVersion = null, promptId =
167
145
 
168
146
  return {
169
147
  resolvedPrompt,
170
- interpolatedVariables,
171
148
  snippetIds,
172
149
  promptId: resolvedPromptId,
173
150
  presetToolIds,
174
- presetSkillIds,
175
151
  presetId: presetId || null,
176
152
  presetVersion: resolvedPresetVersion,
177
153
  modelConfig,
@@ -185,10 +161,16 @@ async function fetchToolsByRecordIds(recordIds) {
185
161
  const cached = toolCache.get(cacheKey);
186
162
  if (cached) return cached;
187
163
 
188
- const formula = `OR(${recordIds.map(id => `RECORD_ID()="${id}"`).join(',')})`;
189
- const tools = await fetchConfigRecords('tools', formula);
190
- toolCache.set(cacheKey, tools);
191
- return tools;
164
+ try {
165
+ const formula = `OR(${recordIds.map(id => `RECORD_ID()="${id}"`).join(',')})`;
166
+ const records = await getRecordByFilter(Config_ID, 'tools', formula);
167
+ const tools = records || [];
168
+ toolCache.set(cacheKey, tools);
169
+ return tools;
170
+ } catch (error) {
171
+ logger.warn('[promptComposer] Failed to fetch preset tools, returning empty list', { error: error.message });
172
+ return [];
173
+ }
192
174
  }
193
175
 
194
176
  async function resolveTools({ promptId, assistant, presetToolIds = null, status = runtimeConfig.getServerMode() }) {
@@ -200,12 +182,7 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
200
182
  if (presetToolIds.length === 0) return { toolIds: [], filtered: true, descriptions: {} };
201
183
 
202
184
  const mappedTools = await fetchToolsByRecordIds(presetToolIds);
203
- if (!mappedTools.length) {
204
- logger.error('[promptComposer] Preset tool ids resolved to no records — running with no tools', {
205
- promptId, linked: presetToolIds.length,
206
- });
207
- return { toolIds: [], filtered: true, descriptions: {} };
208
- }
185
+ if (!mappedTools.length) return { toolIds: [], filtered: false, descriptions: {} };
209
186
 
210
187
  const isProd = status === 'prod';
211
188
  const activeTools = mappedTools.filter(t => !t.status || t.status === 'prod' || (!isProd && t.status === 'dev'));
@@ -217,20 +194,12 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
217
194
  const id = tool.tool_id;
218
195
  if (hasTool(id) || assistant?.tools?.has(id)) {
219
196
  validToolIds.push(id);
220
- // An Airtable column retyped away from long text would otherwise reach the model as [object Object].
221
- if (typeof tool.description === 'string' && tool.description) descriptions[id] = tool.description;
197
+ if (tool.description) descriptions[id] = tool.description;
222
198
  } else {
223
- logger.error('[promptComposer] Tool mapped in Airtable but not registered in code', { toolId: id, promptId });
199
+ logger.warn('[promptComposer] Tool mapped in Airtable but not registered in code', { toolId: id, promptId });
224
200
  }
225
201
  }
226
202
 
227
- if (validToolIds.length === 0) {
228
- logger.error('[promptComposer] Preset links tools but none resolved — the agent will run with NO tools', {
229
- promptId, status, linked: presetToolIds.length,
230
- dropped: mappedTools.map(t => `${t.tool_id}:${t.status || 'prod'}`),
231
- });
232
- }
233
-
234
203
  logger.debug('[promptComposer] Tools resolved', { promptId, mapped: activeTools.length, valid: validToolIds.length, filtered: true });
235
204
 
236
205
  return { toolIds: validToolIds, filtered: true, descriptions };
@@ -14,26 +14,20 @@ async function attachShadowReplies(messages, code) {
14
14
  const assistantMessages = messages.filter((msg) => msg.origin === 'assistant' && msg.response_id);
15
15
  if (!assistantMessages.length) return messages;
16
16
 
17
- const keyed = assistantMessages.filter((msg) => msg.turnId);
18
- const legacy = assistantMessages.filter((msg) => !msg.turnId);
19
-
20
17
  let traces;
21
18
  try {
22
- const or = [];
23
- if (keyed.length) or.push({ turnId: { $in: keyed.map((msg) => msg.turnId) } });
24
- if (legacy.length) {
25
- let min = Infinity;
26
- let max = -Infinity;
27
- messages.forEach((msg) => {
28
- const at = new Date(msg.createdAt).getTime();
29
- if (at < min) min = at;
30
- if (at > max) max = at;
31
- });
32
- or.push({ createdAt: { $gte: new Date(min - PAIRING_WINDOW_MS), $lte: new Date(max + PAIRING_WINDOW_MS) } });
33
- }
19
+ let min = Infinity;
20
+ let max = -Infinity;
21
+ messages.forEach((msg) => {
22
+ const at = new Date(msg.createdAt).getTime();
23
+ if (at < min) min = at;
24
+ if (at > max) max = at;
25
+ });
26
+ const gte = new Date(min - PAIRING_WINDOW_MS);
27
+ const lte = new Date(max + PAIRING_WINDOW_MS);
34
28
  traces = await getTurnTraceModel()
35
- .find({ patientCode: code, outbound: { $ne: null }, $or: or })
36
- .select({ outbound: 1, createdAt: 1, turnId: 1 })
29
+ .find({ patientCode: code, outbound: { $ne: null }, createdAt: { $gte: gte, $lte: lte } })
30
+ .select({ outbound: 1, createdAt: 1 })
37
31
  .sort({ createdAt: 1 })
38
32
  .lean();
39
33
  } catch (error) {
@@ -43,17 +37,7 @@ async function attachShadowReplies(messages, code) {
43
37
  if (!traces.length) return messages;
44
38
 
45
39
  const used = new Set();
46
-
47
- const traceIndexByTurnId = new Map();
48
- traces.forEach((trace, index) => { if (trace.turnId) traceIndexByTurnId.set(trace.turnId, index); });
49
- keyed.forEach((msg) => {
50
- const index = traceIndexByTurnId.get(msg.turnId);
51
- if (index === undefined || used.has(index)) return;
52
- used.add(index);
53
- msg.shadowReply = traces[index].outbound;
54
- });
55
-
56
- legacy.forEach((msg) => {
40
+ assistantMessages.forEach((msg) => {
57
41
  const at = new Date(msg.createdAt).getTime();
58
42
  let bestIndex = -1;
59
43
  let bestDelta = PAIRING_WINDOW_MS;