@peopl-health/nexus 5.68.8 → 5.68.10

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 };
@@ -60,7 +60,7 @@ function routingSkillStep(trace, { enabledSkills = [], skillActivationIteration
60
60
 
61
61
  return {
62
62
  instruction: activatedAt != null
63
- ? `read the ${ROUTING_SKILL} body returned to you (it was requested in this same response, so its content did not reach you)`
63
+ ? `read the ${ROUTING_SKILL} body returned to you (it was returned in this same response, so its content did not reach you yet)`
64
64
  : `call Skill with skill="${ROUTING_SKILL}" and read what it returns`,
65
65
  toolIds: [SKILL_TOOL],
66
66
  };
@@ -154,7 +154,7 @@ function validateClinicalDelivery(trace, { enabledSkills = [], skillActivationIt
154
154
  return {
155
155
  error: REFUSAL.skillBeforeDelivery,
156
156
  recovery: activatedAt != null
157
- ? `The ${downstream} body was requested in this same response, so its content did not reach you. Read it, then compose the patient message from the rules it defines.`
157
+ ? `The ${downstream} body was returned in this same response, so its content did not reach you yet. Read it, then compose the patient message from the rules it defines.`
158
158
  : `The plan you committed routes to ${downstream}. Call Skill with skill="${downstream}", read the body it returns, then compose the patient message from the rules it defines.`,
159
159
  requiredToolIds: [SKILL_TOOL],
160
160
  };
@@ -166,6 +166,7 @@ module.exports = {
166
166
  ROUTING_SKILL,
167
167
  SKILL_TOOL,
168
168
  clinicalIntakeRequiresRisk,
169
+ hasAlternateTerminalCommit,
169
170
  hasUsableRiskContext,
170
171
  isPurelyConversational,
171
172
  validateClinicalDelivery,
@@ -0,0 +1,53 @@
1
+ const { loadSkillBody } = require('../services/skillService');
2
+ const { logger } = require('../../utils/logger');
3
+
4
+ const { SKILL_TOOL } = require('./clinicalWorkflowGuard');
5
+
6
+ function markSkillActivated(runtime, name) {
7
+ runtime?.activatedSkills?.add(name);
8
+ if (runtime?.skillActivationIteration && !runtime.skillActivationIteration.has(name)) {
9
+ runtime.skillActivationIteration.set(name, runtime.currentIteration ?? null);
10
+ }
11
+ }
12
+
13
+ // Hands the model a skill the workflow guard is about to require, inside the result of the tool that made it
14
+ // required, so the turn does not spend an iteration being refused and then asking for it. The guard stays the net.
15
+ async function attachRequiredSkill(runtime, name) {
16
+ if (!runtime || !name) return null;
17
+ if (!(runtime.enabledSkills || []).includes(name)) return null;
18
+ if (!(runtime.trace?.signals?.offeredToolIds || []).includes(SKILL_TOOL)) return null;
19
+ if (runtime.activatedSkills?.has(name)) return null;
20
+
21
+ try {
22
+ const { skill } = await loadSkillBody(name);
23
+ if (!skill) return null;
24
+ markSkillActivated(runtime, skill.name);
25
+ runtime.trace?.setSignals?.({ attachedSkills: [...(runtime.trace.signals?.attachedSkills || []), skill.name] });
26
+ return { name: skill.name, version: skill.version, body: skill.body };
27
+ } catch (error) {
28
+ logger.warn('[skillActivation] required skill could not be attached; the guard will ask for it', {
29
+ skill: name, turnId: runtime.turnId || null, error: error?.message,
30
+ });
31
+ return null;
32
+ }
33
+ }
34
+
35
+ // A skill body governs only the turn it was handed over in. Outside that turn (history replay, monitor evidence)
36
+ // the attachment is dropped, as for a Skill call, whose output is never replayed.
37
+ function withoutAttachedSkillBodies(output) {
38
+ let parsed = output;
39
+ if (typeof output === 'string') {
40
+ try { parsed = JSON.parse(output); } catch (error) { return output; }
41
+ }
42
+ const skills = parsed?.data?.activated_skills;
43
+ if (!Array.isArray(skills)) return output;
44
+ const { activated_skills: _attached, ...data } = parsed.data;
45
+ const stripped = { ...parsed, data };
46
+ return typeof output === 'string' ? JSON.stringify(stripped) : stripped;
47
+ }
48
+
49
+ module.exports = {
50
+ attachRequiredSkill,
51
+ markSkillActivated,
52
+ withoutAttachedSkillBodies,
53
+ };
@@ -1,5 +1,6 @@
1
1
  const { patientMessageFromTrace } = require('../helpers/evidenceAnchorHelper');
2
2
  const { parseGradeEstimate, projectGradeEstimate } = require('../helpers/gradeEstimateHelper');
3
+ const { withoutAttachedSkillBodies } = require('../helpers/skillActivationHelper');
3
4
  const { redactDirectIdentifiers } = require('../../utils/sanitizerUtils');
4
5
  const { logger } = require('../../utils/logger');
5
6
 
@@ -256,7 +257,7 @@ function compactStep(step) {
256
257
  return {
257
258
  tool_name: step.tool_name,
258
259
  arguments: minimized(step.tool_arguments || null),
259
- output: minimized(step.tool_output || null),
260
+ output: minimized(withoutAttachedSkillBodies(step.tool_output) || null),
260
261
  success: step.success !== false,
261
262
  iteration: step.iteration ?? null,
262
263
  };
@@ -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 || '',
@@ -619,6 +625,11 @@ class BaseLLMProvider {
619
625
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
620
626
  try {
621
627
  logger.info('[runConversation] Attempt', { attempt, maxRetries, threadId, assistantId });
628
+ // A retry starts from the original input, so a skill body handed over in an earlier attempt is not in context.
629
+ if (attempt > 1) {
630
+ config?.assistant?.toolRuntimeContext?.activatedSkills?.clear();
631
+ config?.assistant?.toolRuntimeContext?.skillActivationIteration?.clear();
632
+ }
622
633
 
623
634
  const result = await this._executeConversation(config);
624
635
  // DeliverPatientMessage is the curated patient reply; prefer it over any model narration in the final response.
@@ -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() {
@@ -1,8 +1,9 @@
1
1
  const { requireGatewayProvider } = require('../config/llmConfig');
2
2
  const { getSafetyFlagPatterns } = require('../config/divergenceConfig');
3
- const { isPurelyConversational } = require('../helpers/clinicalWorkflowGuard');
3
+ const { ROUTING_SKILL, isPurelyConversational } = require('../helpers/clinicalWorkflowGuard');
4
4
  const { detectSafetyFlags } = require('../helpers/safetyFlagsHelper');
5
5
  const { projectClinicalFacts, renderClinicalIntakeSummary } = require('../helpers/clinicalFactsHelper');
6
+ const { attachRequiredSkill } = require('../helpers/skillActivationHelper');
6
7
  const { extractClinicalMentions, getConversationalTokens } = require('../services/clinicalExtractionService');
7
8
  const { recordClinicalIntake } = require('../services/clinicalAirtableService');
8
9
  const { getCtcaeCatalog } = require('../stores/CtcaeCatalog');
@@ -40,7 +41,7 @@ const MAX_CONTEXT_TURNS = 6;
40
41
 
41
42
  const definition = {
42
43
  name: 'extractClinicalInfo',
43
- description: '**Does:** Extracts ALL structured clinical facts from the current patient message — symptoms, labs, medications, diagnoses, appointments, treatments — and returns them as a discriminated-union `clinical_facts[]` list keyed by `kind`. Call ONCE per turn with ALL detected tags in a single call — one call covers every category.\n\n**Required inputs:** `raw_message` (the patient\'s current turn text, VERBATIM — the full contents of `<patient_message>`, every line of it when the turn carries more than one), `detected_tags` (list, at least one of: `symptom`, `indication`, `treatment`, `adherence`, `medication`, `appointment`, `emotion`, `lab_value`, `diagnosis`, `allergy`, `comorbidity`, `performance_status`; empty only for a purely conversational message or when declaring `non_clinical_request`), `intake_source` (`self` | `caregiver` | `team_relay`).\n\n**When to call:** at the start of any turn where the patient message mentions, explicitly or implicitly, any of the 12 supported categories. The result drives every downstream router-side read (landscape / history / risk / patterns).\n\n**When NOT to call:**\n- When the patient SHARED raw numeric results / lab values / files — use `reportResultsReceived` instead (it both logs the share AND emits FHIR Observations directly).\n- More than once per turn — a single call covers every category.\n\n**Returns:** `data.clinical_facts[]` — a discriminated-union list where each record has `kind` (observation | medication | condition | procedure | appointment | allergy) plus common fields `{id, code, effective, notes}` (null/default fields are OMITTED — e.g. `interpretation` appears only when non-default) and kind-specific fields:\n- `kind=observation`: `code_ctcae`, `value`, `grade_scale`, `episode_id`, `trend`. Read `grade_scale` to anchor the grade assigned in `recordClinicalImpression`. It is present whenever the term has a curated CTCAE ladder, and ABSENT when it does not — an absent `grade_scale` means there is no rubric for this term, not that one was withheld. An absent `grade_scale` means the term has no curated rubric, not that one was withheld — so there is nothing to grade against from memory. Call `lookupCtcaeEntry` when you suspect the term itself is wrong; otherwise say where the grade came from in `grade_estimate.source`.\n- `kind=medication`: `adherence_status` (`started` | `ongoing` | `non_adherent` | …).\n- `kind=condition`: `clinical_status` (`active` | `resolved` | …) — patient self-report of an existing diagnosis (distinct from `openCondition` which commits a new agent-tracked case).\n- `kind=procedure`: patient-reported procedure done elsewhere (distinct from `recordIntervention` which is agent-authored); `venue`/`outcome` may be null in the current extraction phase.\n- `kind=appointment`: `scheduled_for`, `purpose` (may be null in the current extraction phase).\nThe system writes the matching FHIR resource (Observation / MedicationStatement / Condition / Procedure / Appointment) + `Provenance` per fact on the audit lane; `allergy`, `comorbidity`, and `performance_status` are captured in a dedicated aggregate, not as a per-fact resource.\n\n**When the result asks you to confirm:** `data.pending_confirmation[]` means the patient reported a REAL clinical event but named it only generically («me suspendieron el tratamiento»), so it was deliberately NOT recorded. Each entry carries `{term, category, missing, quote}`; the list holds EVERY fact withheld this turn, but ask about the FIRST one only. Ask the patient for exactly what its `missing` names, in ONE short natural question, and never state or imply that anything was recorded. When they answer, call `extractClinicalInfo` again on their reply and it will be recorded then. `data.context_gaps[]` — only ever sent when there is nothing to confirm — names ONE stored variable that is stale or incomplete; raise it only if the conversation allows it naturally. Both lists are empty whenever the turn carries a safety flag: never ask housekeeping questions during a safety event.\n\n**Side effects:** System emits a FHIR resource + `Provenance` per fact on the audit lane, except `allergy`/`comorbidity`/`performance_status` (aggregated). Facts listed in `pending_confirmation` are NOT recorded as facts — they never reach the clinical intake or the patient\'s chart; FHIR keeps them only as unconfirmed drafts until the patient confirms them.',
44
+ description: '**Does:** Extracts ALL structured clinical facts from the current patient message — symptoms, labs, medications, diagnoses, appointments, treatments — and returns them as a discriminated-union `clinical_facts[]` list keyed by `kind`. Call ONCE per turn with ALL detected tags in a single call — one call covers every category.\n\n**Required inputs:** `raw_message` (the patient\'s current turn text, VERBATIM — the full contents of `<patient_message>`, every line of it when the turn carries more than one), `detected_tags` (list, at least one of: `symptom`, `indication`, `treatment`, `adherence`, `medication`, `appointment`, `emotion`, `lab_value`, `diagnosis`, `allergy`, `comorbidity`, `performance_status`; empty only for a purely conversational message or when declaring `non_clinical_request`), `intake_source` (`self` | `caregiver` | `team_relay`).\n\n**When to call:** at the start of any turn where the patient message mentions, explicitly or implicitly, any of the 12 supported categories. The result drives every downstream router-side read (landscape / history / risk / patterns).\n\n**When NOT to call:**\n- When the patient SHARED raw numeric results / lab values / files — use `reportResultsReceived` instead (it both logs the share AND emits FHIR Observations directly).\n- More than once per turn — a single call covers every category.\n\n**Returns:** `data.clinical_facts[]` — a discriminated-union list where each record has `kind` (observation | medication | condition | procedure | appointment | allergy) plus common fields `{id, code, effective, notes}` (null/default fields are OMITTED — e.g. `interpretation` appears only when non-default) and kind-specific fields:\n- `kind=observation`: `code_ctcae`, `value`, `grade_scale`, `episode_id`, `trend`. Read `grade_scale` to anchor the grade assigned in `recordClinicalImpression`. It is present whenever the term has a curated CTCAE ladder, and ABSENT when it does not — an absent `grade_scale` means there is no rubric for this term, not that one was withheld. An absent `grade_scale` means the term has no curated rubric, not that one was withheld — so there is nothing to grade against from memory. Call `lookupCtcaeEntry` when you suspect the term itself is wrong; otherwise say where the grade came from in `grade_estimate.source`.\n- `kind=medication`: `adherence_status` (`started` | `ongoing` | `non_adherent` | …).\n- `kind=condition`: `clinical_status` (`active` | `resolved` | …) — patient self-report of an existing diagnosis (distinct from `openCondition` which commits a new agent-tracked case).\n- `kind=procedure`: patient-reported procedure done elsewhere (distinct from `recordIntervention` which is agent-authored); `venue`/`outcome` may be null in the current extraction phase.\n- `kind=appointment`: `scheduled_for`, `purpose` (may be null in the current extraction phase).\nThe system writes the matching FHIR resource (Observation / MedicationStatement / Condition / Procedure / Appointment) + `Provenance` per fact on the audit lane; `allergy`, `comorbidity`, and `performance_status` are captured in a dedicated aggregate, not as a per-fact resource.\n\n`data.activated_skills[]` — present when clinical facts were extracted and `clinical_routing` is enabled but not yet loaded this turn: its `body` is the routing protocol. Read it before `submitRoutingDecision`; no `Skill` call needed.\n\n**When the result asks you to confirm:** `data.pending_confirmation[]` means the patient reported a REAL clinical event but named it only generically («me suspendieron el tratamiento»), so it was deliberately NOT recorded. Each entry carries `{term, category, missing, quote}`; the list holds EVERY fact withheld this turn, but ask about the FIRST one only. Ask the patient for exactly what its `missing` names, in ONE short natural question, and never state or imply that anything was recorded. When they answer, call `extractClinicalInfo` again on their reply and it will be recorded then. `data.context_gaps[]` — only ever sent when there is nothing to confirm — names ONE stored variable that is stale or incomplete; raise it only if the conversation allows it naturally. Both lists are empty whenever the turn carries a safety flag: never ask housekeeping questions during a safety event.\n\n**Side effects:** System emits a FHIR resource + `Provenance` per fact on the audit lane, except `allergy`/`comorbidity`/`performance_status` (aggregated). Facts listed in `pending_confirmation` are NOT recorded as facts — they never reach the clinical intake or the patient\'s chart; FHIR keeps them only as unconfirmed drafts until the patient confirms them.',
44
45
  strict: true,
45
46
  parameters: {
46
47
  type: 'object',
@@ -265,6 +266,10 @@ async function handler(args, context = {}) {
265
266
  const gradeScales = {};
266
267
  for (const term of terms) gradeScales[term] = catalog.getGradeScale(term);
267
268
  const envelope = projectClinicalFacts({ clinicalMentions, safetyFlags, gradeScales, askAgenda });
269
+ const routingSkill = clinicalMentions.length || pendingMentions.length
270
+ ? await attachRequiredSkill(runtime, ROUTING_SKILL)
271
+ : null;
272
+ if (routingSkill) envelope.data.activated_skills = [routingSkill];
268
273
  return JSON.stringify(envelope);
269
274
  }
270
275
 
@@ -1,9 +1,10 @@
1
1
  const { MEDICATION_NAMING_RULE } = require('../helpers/medicationNamingRule');
2
+ const { markSkillActivated } = require('../helpers/skillActivationHelper');
2
3
  const { loadSkillBody } = require('../services/skillService');
3
4
 
4
5
  const definition = {
5
6
  name: 'Skill',
6
- description: '**Does:** Activates an enabled skill by name and returns its `SKILL.md` body inline; the body then governs the rest of the turn.\n\n**Required inputs:** `skill` (enabled skill name, no leading slash, e.g. `emergency`), `args` (context string the skill body can interpret — pass `null` when there is no additional context).\n\n**When to call:** the turn matches a protocol the operator has enabled for this run (e.g. an emergency/red-flag turn) and you need its full body to proceed correctly.\n\n**When NOT to call:** the skill is not enabled for this run; a plain reply covers the turn without a protocol.\n\n**Returns:** `activated_skills[]` — `{name, version, body}`; read `body` and follow it verbatim. A skill not enabled for this run returns the enabled skill names, and an unknown name returns the available skill names, in the error payload for self-correction. Read-only.',
7
+ description: '**Does:** Activates an enabled skill by name and returns its `SKILL.md` body inline; the body then governs the rest of the turn.\n\n**Required inputs:** `skill` (enabled skill name, no leading slash, e.g. `emergency`), `args` (context string the skill body can interpret — pass `null` when there is no additional context).\n\n**When to call:** the turn matches a protocol the operator has enabled for this run (e.g. an emergency/red-flag turn) and you need its full body to proceed correctly.\n\n**When NOT to call:** the skill is not enabled for this run; a plain reply covers the turn without a protocol.\n\n**Returns:** `activated_skills[]` — `{name, version, body}`; read `body` and follow it verbatim. A skill already returned this turn (by `Skill` or inside another tool\'s `activated_skills`) returns `already_active` instead of the body again. A skill not enabled for this run returns the enabled skill names, and an unknown name returns the available skill names, in the error payload for self-correction. Read-only.',
7
8
  strict: true,
8
9
  replayScope: 'turn',
9
10
  parameters: {
@@ -46,6 +47,14 @@ async function handler(args, context) {
46
47
  });
47
48
  }
48
49
 
50
+ const runtime = context?.toolRuntimeContext;
51
+ if (runtime?.activatedSkills?.has(name)) {
52
+ return JSON.stringify({
53
+ success: true,
54
+ data: { already_active: name, note: `The ${name} body was already returned to you in this turn; follow it from there.` },
55
+ });
56
+ }
57
+
49
58
  const { skill, availableSkills } = await loadSkillBody(name);
50
59
  if (!skill) {
51
60
  return JSON.stringify({
@@ -58,11 +67,7 @@ async function handler(args, context) {
58
67
  const fragment = { name: skill.name, version: skill.version, body: skill.body };
59
68
  if (args?.args) fragment.args_received = args.args;
60
69
 
61
- const runtime = context?.toolRuntimeContext;
62
- runtime?.activatedSkills?.add(skill.name);
63
- if (runtime?.skillActivationIteration && !runtime.skillActivationIteration.has(skill.name)) {
64
- runtime.skillActivationIteration.set(skill.name, runtime.currentIteration ?? null);
65
- }
70
+ markSkillActivated(runtime, skill.name);
66
71
 
67
72
  return JSON.stringify({ success: true, data: { activated_skills: [fragment] } });
68
73
  } catch (err) {
@@ -1,7 +1,8 @@
1
1
  const crypto = require('node:crypto');
2
2
 
3
- const { validateRoutingPrerequisites } = require('../helpers/clinicalWorkflowGuard');
3
+ const { clinicalIntakeRequiresRisk, hasAlternateTerminalCommit, validateRoutingPrerequisites } = require('../helpers/clinicalWorkflowGuard');
4
4
  const { MEDICATION_NAMING_RULE } = require('../helpers/medicationNamingRule');
5
+ const { attachRequiredSkill } = require('../helpers/skillActivationHelper');
5
6
  const { storeRoutingDecision, readRoutingDecisions, readSymptomCases } = require('../../fhir');
6
7
  const { RoutingDecision } = require('../../shared/dtos/RoutingDecision');
7
8
  const { foldAccents } = require('../../utils/formatUtils');
@@ -92,7 +93,7 @@ const _CASE_IMPLYING_ACTIONS = new Set([
92
93
 
93
94
  const definition = {
94
95
  name: 'submitRoutingDecision',
95
- description: '**Does:** The terminal routing checkpoint — call ONCE per turn to commit the turn plan. Decomposes `trace` (the per-mention evaluation record) and `turn_plan` (the primary action + optional secondary/deferred) into a persisted RoutingDecision (assessment + disposition + provenance), sets the turn\'s outbound signals, and hands the agent the next skill to invoke.\n\n**Required inputs:** `trace.mentions_evaluated[]` — one entry per clinical mention: `clinical_reasoning`, `decision.action`, `case_id_opened_or_updated` — REQUIRED whenever `decision.action` acts on a symptom (`emergency_escalation`, `team_escalation`, `symptom_recommendation`, `symptom_probe`, `acknowledge_improvement_on_case`, `follow_up_on_open_case`, `revisit_neglected_case`, `educate_about_symptom`, `weekly_triage_recommendations`, `generate_recommendations`), null only for a genuinely caseless mention (a medication, an appointment, a mood), plus `safety_flag_disposition` (string or object) when `safety_flags_received` is non-empty. `turn_plan.primary` — exactly one `{action, reason, case_id?}`; `turn_plan.secondary[]` — up to 2 `{action, compose_as}`; `turn_plan.deferred[]` — optional `{item?, defer_reason}`.\n\n**Routing-skill prerequisite:** when `clinical_routing` is among your enabled skills and `Skill` is offered to you this turn, this checkpoint fails until that body has been read in an EARLIER iteration. Presets without either proceed straight to routing. Requesting it in the same response does not count — its content never reached you. Call `Skill` with `skill=clinical_routing`, read what it returns, then commit here. This is the most common reason a first attempt is rejected.\n\n**Risk prerequisite:** after a clinical intake with extracted mentions, this checkpoint fails until the turn trace records a successful, processed risk read with status `final`, `no_signal`, or `not_generated`. Normally call `getRouterContextBundle` first and use its `risk.turn_focus`; if `sections_returned` lacks `risk` or reports `_diagnostics.risk_error`, call `getPatientRiskAssessment`, then retry this checkpoint.\n\n**Enum coercion:** primary/secondary `action` and secondary `compose_as` are folded (case/accent/separator) and synonym-mapped to canonical values; each repair is disclosed via `auto_repairs`. A genuinely unknown action is rejected.\n\n**Returns:** `decision_id`, `downstream_skill_to_invoke` (emergency | accompaniment | symptom_management | patient_education | null, derived from `primary.action`), and `auto_repairs` (only when non-empty). Fails hard with `risk_assessment_required_before_routing` when clinical intake ran without a processed risk disposition, with `decision_already_committed_for_turn` if called twice in one turn, when a symptom-acting mention carries no `case_id_opened_or_updated`, and when a named `case_id` does not exist for this patient. A clinical `DeliverPatientMessage` remains blocked until this commit succeeds.\n\n**Side effects:** persists the RoutingDecision; sets `branch_taken=ai_agent` on the turn trace.',
96
+ description: '**Does:** The terminal routing checkpoint — call ONCE per turn to commit the turn plan. Decomposes `trace` (the per-mention evaluation record) and `turn_plan` (the primary action + optional secondary/deferred) into a persisted RoutingDecision (assessment + disposition + provenance), sets the turn\'s outbound signals, and returns the body of the skill that governs the reply.\n\n**Required inputs:** `trace.mentions_evaluated[]` — one entry per clinical mention: `clinical_reasoning`, `decision.action`, `case_id_opened_or_updated` — REQUIRED whenever `decision.action` acts on a symptom (`emergency_escalation`, `team_escalation`, `symptom_recommendation`, `symptom_probe`, `acknowledge_improvement_on_case`, `follow_up_on_open_case`, `revisit_neglected_case`, `educate_about_symptom`, `weekly_triage_recommendations`, `generate_recommendations`), null only for a genuinely caseless mention (a medication, an appointment, a mood), plus `safety_flag_disposition` (string or object) when `safety_flags_received` is non-empty. `turn_plan.primary` — exactly one `{action, reason, case_id?}`; `turn_plan.secondary[]` — up to 2 `{action, compose_as}`; `turn_plan.deferred[]` — optional `{item?, defer_reason}`.\n\n**Routing-skill prerequisite:** when `clinical_routing` is among your enabled skills and `Skill` is offered to you this turn, this checkpoint fails until that body has been read in an EARLIER iteration. Presets without either proceed straight to routing. `extractClinicalInfo` returns that body in its `activated_skills` when it extracts clinical mentions: read it there and commit here in a LATER response, not in the same one as the intake. If no body was returned, call `Skill` with `skill=clinical_routing` first.\n\n**Risk prerequisite:** after a clinical intake with extracted mentions, this checkpoint fails until the turn trace records a successful, processed risk read with status `final`, `no_signal`, or `not_generated`. Normally call `getRouterContextBundle` first and use its `risk.turn_focus`; if `sections_returned` lacks `risk` or reports `_diagnostics.risk_error`, call `getPatientRiskAssessment`, then retry this checkpoint.\n\n**Enum coercion:** primary/secondary `action` and secondary `compose_as` are folded (case/accent/separator) and synonym-mapped to canonical values; each repair is disclosed via `auto_repairs`. A genuinely unknown action is rejected.\n\n**Returns:** `decision_id`, `downstream_skill_to_invoke` (emergency | accompaniment | symptom_management | patient_education | null, derived from `primary.action`), `activated_skills` (that skill\'s body, when it is enabled and not yet loaded this turn — read it and compose the reply from it; no `Skill` call needed), and `auto_repairs` (only when non-empty). Fails hard with `risk_assessment_required_before_routing` when clinical intake ran without a processed risk disposition, with `decision_already_committed_for_turn` if called twice in one turn, when a symptom-acting mention carries no `case_id_opened_or_updated`, and when a named `case_id` does not exist for this patient. A clinical `DeliverPatientMessage` remains blocked until this commit succeeds.\n\n**Side effects:** persists the RoutingDecision; sets `branch_taken=ai_agent` on the turn trace.',
96
97
  strict: false,
97
98
  parameters: {
98
99
  type: 'object',
@@ -431,6 +432,9 @@ async function handler(args = {}, context = {}) {
431
432
  const downstream = derivedDownstreamSkill(plan.primaryAction);
432
433
  runtime.trace.setSignals({ downstreamSkill: downstream || null });
433
434
  const data = { decision_id: decision.decisionId, downstream_skill_to_invoke: downstream };
435
+ const deliveryNeedsSkill = clinicalIntakeRequiresRisk(runtime.trace) && !hasAlternateTerminalCommit(runtime.trace);
436
+ const downstreamSkill = deliveryNeedsSkill ? await attachRequiredSkill(runtime, downstream) : null;
437
+ if (downstreamSkill) data.activated_skills = [downstreamSkill];
434
438
  if (repairs.length) data.auto_repairs = repairs;
435
439
  return JSON.stringify({ success: true, data });
436
440
  } catch (err) {
@@ -3,6 +3,8 @@ const moment = require('moment-timezone');
3
3
  const { logger } = require('../utils/logger');
4
4
  const { isPlainObject } = require('../utils/jsonUtils');
5
5
 
6
+ const { withoutAttachedSkillBodies } = require('../clinical/helpers/skillActivationHelper');
7
+
6
8
  function formatMessage(reply) {
7
9
  try {
8
10
  if (!reply.createdAt) return [];
@@ -62,9 +64,8 @@ function getMessageTools(reply, { skipTools, replayArgs } = {}) {
62
64
  const argsStr = JSON.stringify(args);
63
65
  let outputStr;
64
66
  if (t.tool_output != null && !keepArgs) {
65
- outputStr = typeof t.tool_output === 'string'
66
- ? t.tool_output
67
- : JSON.stringify(t.tool_output);
67
+ const output = withoutAttachedSkillBodies(t.tool_output);
68
+ outputStr = typeof output === 'string' ? output : JSON.stringify(output);
68
69
  } else {
69
70
  outputStr = JSON.stringify({ success: t.success !== false });
70
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.68.8",
3
+ "version": "5.68.10",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",