@peopl-health/nexus 5.68.9 → 5.68.11
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.
- package/lib/clinical/helpers/clinicalWorkflowGuard.js +3 -2
- package/lib/clinical/helpers/skillActivationHelper.js +53 -0
- package/lib/clinical/monitoring/contingencyMonitorSpec.js +2 -1
- package/lib/clinical/providers/BaseLLMProvider.js +5 -0
- package/lib/clinical/services/clinicalPatternFloorService.js +48 -6
- package/lib/clinical/tools/extractClinicalInfoTool.js +7 -2
- package/lib/clinical/tools/reportMedicalEscalationTool.js +1 -0
- package/lib/clinical/tools/skillTool.js +11 -6
- package/lib/clinical/tools/submitRoutingDecisionTool.js +6 -2
- package/lib/helpers/messageHelper.js +4 -3
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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,3 +1,5 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
1
3
|
const { getEscalationPatternFloor } = require('../config/subAgentsConfig');
|
|
2
4
|
const { requireGatewayProvider } = require('../config/llmConfig');
|
|
3
5
|
const { readClinicalProfile } = require('../helpers/clinicalProfileHelper');
|
|
@@ -9,13 +11,17 @@ const { isPlainObject, safeParse } = require('../../utils/jsonUtils');
|
|
|
9
11
|
const { logger } = require('../../utils/logger');
|
|
10
12
|
const { redactDirectIdentifiers } = require('../../utils/sanitizerUtils');
|
|
11
13
|
const { withTimeout } = require('../../utils/timeoutUtils');
|
|
14
|
+
const { composePrompt } = require('./promptComposerService');
|
|
12
15
|
|
|
13
16
|
const FLOOR_LEVELS = ['asap', 'stat'];
|
|
14
17
|
const PATTERN_NAME = /^[a-z0-9_]{1,64}$/;
|
|
15
18
|
const UNLABELLED_PATTERN = 'sin_etiqueta';
|
|
16
19
|
const MAX_TEXT_LENGTH = 500;
|
|
20
|
+
const MAX_LEVEL_LENGTH = 32;
|
|
21
|
+
const MAX_PATTERN_LENGTH = 64;
|
|
22
|
+
const PROVENANCE_GRACE_MS = 500;
|
|
17
23
|
|
|
18
|
-
const degraded = (reason) => ({ floor: null, degraded: reason });
|
|
24
|
+
const degraded = (reason, check = null) => ({ floor: null, degraded: reason, check });
|
|
19
25
|
|
|
20
26
|
function normalizePattern(raw) {
|
|
21
27
|
const name = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
|
@@ -24,6 +30,32 @@ function normalizePattern(raw) {
|
|
|
24
30
|
|
|
25
31
|
const clippedText = (value) => cleanText(value)?.slice(0, MAX_TEXT_LENGTH) || null;
|
|
26
32
|
|
|
33
|
+
// Which prompt judged this escalation. Prompt text is edited in Airtable without bumping the preset
|
|
34
|
+
// version, so the version alone cannot tell two prompts apart; the hash of the composed prompt can.
|
|
35
|
+
async function promptProvenance(presetId, turnId) {
|
|
36
|
+
try {
|
|
37
|
+
// Composed alongside runStructured's own compose, with the same inputs and caches: it names the prompt
|
|
38
|
+
// the model ran with, though an Airtable edit landing between the two reads could make them differ.
|
|
39
|
+
const composed = await composePrompt({ presetId });
|
|
40
|
+
// A missing preset composes to an empty prompt; a hash of nothing would read as real provenance.
|
|
41
|
+
const promptHash = composed.resolvedPrompt
|
|
42
|
+
? crypto.createHash('sha256').update(`${composed.resolvedPrompt}\n${JSON.stringify(composed.modelConfig || null)}`).digest('hex').slice(0, 12)
|
|
43
|
+
: null;
|
|
44
|
+
return { presetId, presetVersion: composed.presetVersion || null, promptHash };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
logger.warn('[clinicalPatternFloor] The prompt provenance could not be read', { turnId, error: error.message });
|
|
47
|
+
return { presetId, presetVersion: null, promptHash: null };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// What the model answered, before validation, so a rejected or empty answer can be audited too.
|
|
52
|
+
const rawOutput = (parsed) => ({
|
|
53
|
+
minLevel: typeof parsed.min_level === 'string' ? parsed.min_level.slice(0, MAX_LEVEL_LENGTH) : null,
|
|
54
|
+
pattern: typeof parsed.pattern === 'string' ? parsed.pattern.slice(0, MAX_PATTERN_LENGTH) : null,
|
|
55
|
+
evidence: clippedText(parsed.evidence),
|
|
56
|
+
reason: clippedText(parsed.reason),
|
|
57
|
+
});
|
|
58
|
+
|
|
27
59
|
function buildPatternInput({ profile, details, justification, today }) {
|
|
28
60
|
const indicators = Array.isArray(details.indicators) ? details.indicators.filter((item) => typeof item === 'string' && item.trim()) : [];
|
|
29
61
|
const exposure = buildExposureBlock(profile);
|
|
@@ -55,32 +87,42 @@ async function evaluatePatternFloor({ code, turnId, details, justification, prov
|
|
|
55
87
|
logger.warn('[clinicalPatternFloor] The clinical profile is missing or unreadable; the check is skipped', { turnId, profile: reason });
|
|
56
88
|
return degraded(reason);
|
|
57
89
|
}
|
|
90
|
+
// Read alongside the model call so the audit never spends the floor's deadline.
|
|
91
|
+
const pendingProvenance = promptProvenance(presetId, turnId);
|
|
58
92
|
const result = await (provider || requireGatewayProvider()).runStructured({
|
|
59
93
|
presetId,
|
|
60
94
|
input: [{ role: 'user', content: buildPatternInput({ profile, details, justification, today: now.toISOString().slice(0, 10) }) }],
|
|
61
95
|
metadata: { subAgent: 'escalation-pattern-floor', turnId },
|
|
62
96
|
});
|
|
97
|
+
// Once the model has answered, provenance gets a short grace and is otherwise dropped: a slow
|
|
98
|
+
// Airtable read must never turn an answer the floor already has into a timeout.
|
|
99
|
+
const provenance = await withTimeout(pendingProvenance, PROVENANCE_GRACE_MS, 'pattern floor provenance').catch((error) => {
|
|
100
|
+
logger.warn('[clinicalPatternFloor] The prompt provenance was not ready; the answer is kept without it', { turnId, error: error.message });
|
|
101
|
+
return { presetId, presetVersion: null, promptHash: null };
|
|
102
|
+
});
|
|
63
103
|
if (result?.status && result.status !== 'completed') {
|
|
64
104
|
logger.warn('[clinicalPatternFloor] The pattern check did not complete', { turnId, status: result.status });
|
|
65
|
-
return degraded('incomplete');
|
|
105
|
+
return degraded('incomplete', { ...provenance, output: null });
|
|
66
106
|
}
|
|
67
107
|
const parsed = safeParse(readStructuredText(result), null);
|
|
68
108
|
if (!isPlainObject(parsed)) {
|
|
69
109
|
logger.warn('[clinicalPatternFloor] The pattern check did not return a JSON object', { turnId });
|
|
70
|
-
return degraded('malformed_output');
|
|
110
|
+
return degraded('malformed_output', { ...provenance, output: null });
|
|
71
111
|
}
|
|
112
|
+
const check = { ...provenance, output: rawOutput(parsed) };
|
|
72
113
|
if (parsed.min_level == null) {
|
|
73
|
-
if (parsed.pattern == null) return { floor: null, degraded: null };
|
|
114
|
+
if (parsed.pattern == null) return { floor: null, degraded: null, check };
|
|
74
115
|
logger.warn('[clinicalPatternFloor] The pattern check named a pattern without a level', { turnId, pattern: parsed.pattern });
|
|
75
|
-
return degraded('missing_level');
|
|
116
|
+
return degraded('missing_level', check);
|
|
76
117
|
}
|
|
77
118
|
if (!FLOOR_LEVELS.includes(parsed.min_level)) {
|
|
78
119
|
logger.warn('[clinicalPatternFloor] The pattern check returned an unsupported level', { turnId, minLevel: parsed.min_level, pattern: parsed.pattern });
|
|
79
|
-
return degraded('unsupported_level');
|
|
120
|
+
return degraded('unsupported_level', check);
|
|
80
121
|
}
|
|
81
122
|
return {
|
|
82
123
|
floor: { level: parsed.min_level, pattern: normalizePattern(parsed.pattern), evidence: clippedText(parsed.evidence), reason: clippedText(parsed.reason) },
|
|
83
124
|
degraded: null,
|
|
125
|
+
check,
|
|
84
126
|
};
|
|
85
127
|
}
|
|
86
128
|
|
|
@@ -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
|
|
|
@@ -349,6 +349,7 @@ async function handler(args, context = {}) {
|
|
|
349
349
|
...(patternFloor ? { clinicalPatternFloor: { ...patternFloor, raised: raisedByPattern } } : {}),
|
|
350
350
|
...(gateCheck?.degraded ? { triageGateFloorDegraded: gateCheck.degraded } : {}),
|
|
351
351
|
...(patternCheck?.degraded ? { clinicalPatternFloorDegraded: patternCheck.degraded } : {}),
|
|
352
|
+
...(patternCheck?.check ? { clinicalPatternFloorCheck: patternCheck.check } : {}),
|
|
352
353
|
},
|
|
353
354
|
});
|
|
354
355
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
}
|