@peopl-health/nexus 5.68.9 → 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.
@@ -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
  };
@@ -625,6 +625,11 @@ class BaseLLMProvider {
625
625
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
626
626
  try {
627
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
+ }
628
633
 
629
634
  const result = await this._executeConversation(config);
630
635
  // DeliverPatientMessage is the curated patient reply; prefer it over any model narration in the final response.
@@ -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.9",
3
+ "version": "5.68.10",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",