@peopl-health/nexus 5.69.0-dev.12295 → 5.69.0-dev.12577

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
@@ -348,15 +348,27 @@ class AnthropicProvider extends BaseLLMProvider {
348
348
  };
349
349
  if (system) apiConfig.system = system;
350
350
 
351
+ // Always-thinking models (Opus/Sonnet 5.5) reject a forced tool_choice; presets opt into output_config.format instead.
352
+ const nativeFormat = modelConfig.structured_mode === 'output_format';
353
+ if (nativeFormat) {
354
+ const effort = modelConfig.reasoning?.effort;
355
+ if (effort) apiConfig.output_config = { effort: effort === 'minimal' ? 'low' : effort };
356
+ if (modelConfig.thinking) apiConfig.thinking = modelConfig.thinking;
357
+ }
358
+
351
359
  const spec = text || modelConfig.text;
352
360
  let tool = null;
353
361
  if (spec) {
354
362
  const format = spec.format ? spec.format : spec;
355
363
  if (!format.schema) throw new Error('runStructured: format spec has no schema');
356
- tool = { name: format.name || 'structuredOutput', input_schema: format.schema };
357
- if (format.strict !== undefined) tool.strict = format.strict;
358
- apiConfig.tools = [tool];
359
- apiConfig.tool_choice = { type: 'tool', name: tool.name };
364
+ if (nativeFormat) {
365
+ apiConfig.output_config = { ...apiConfig.output_config, format: { type: 'json_schema', schema: format.schema } };
366
+ } else {
367
+ tool = { name: format.name || 'structuredOutput', input_schema: format.schema };
368
+ if (format.strict !== undefined) tool.strict = format.strict;
369
+ apiConfig.tools = [tool];
370
+ apiConfig.tool_choice = { type: 'tool', name: tool.name };
371
+ }
360
372
  }
361
373
 
362
374
  const { result: { result }, durationMs } = await withTiming(() =>
@@ -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.
@@ -19,6 +19,7 @@ const { logger } = require('../../utils/logger');
19
19
  const CONFIG_TABLE = 'registry';
20
20
  const CACHE_TTL = 5 * 60 * 1000;
21
21
  const CONFIG_CACHE_KEY = 'clinicalExtractorConfig';
22
+ const ENDING_STATUSES = new Set(['paused', 'completed']);
22
23
 
23
24
  const extractorConfigCache = new MapCache({ maxSize: 1, ttl: CACHE_TTL });
24
25
 
@@ -37,6 +38,12 @@ function namesNothingSpecific(canonicalTerm, vagueWords) {
37
38
  return tokens.length > 0 && tokens.every((token) => vagueWords.has(token));
38
39
  }
39
40
 
41
+ function endsNamedLine(raw, fact) {
42
+ return fact.category === 'treatment'
43
+ && Boolean(raw.treatment_line)
44
+ && ENDING_STATUSES.has(fact.observedFields?.status);
45
+ }
46
+
40
47
  async function loadPatientContext(code) {
41
48
  try {
42
49
  const { clinicalContext, contextAsk } = await getClinicalContext(code);
@@ -115,7 +122,8 @@ async function extractClinicalMentions({ text, turnId, code, provider, detectedT
115
122
  unanchored += 1;
116
123
  return;
117
124
  }
118
- if (!mention.provenance.needsConfirmation && namesNothingSpecific(mention.fact.canonicalTerm, vagueWords)) {
125
+ if (!mention.provenance.needsConfirmation && !endsNamedLine(raw, mention.fact)
126
+ && namesNothingSpecific(mention.fact.canonicalTerm, vagueWords)) {
119
127
  forcedPending += 1;
120
128
  mention = toClinicalMention(
121
129
  { ...raw, needs_confirmation: true, missing: raw.missing?.length ? raw.missing : ['name'] },
@@ -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
 
@@ -62,7 +62,6 @@ const CONTACT_SENT = {
62
62
  const RECENT_OUTBOUND_HOURS = 24;
63
63
  const COLLIDING_REMINDER_MINUTES = 24 * 60;
64
64
  const INACTIVITY_DAYS = 7;
65
- const MAX_SILENCE_DAYS = 30;
66
65
  const TRIAGE_PROTECTION_START_MINUTE = (7 * 60) + 30;
67
66
  const TRIAGE_PROTECTION_END_MINUTE = 11 * 60;
68
67
 
@@ -270,13 +269,11 @@ async function rejectionReason(candidate, {
270
269
  if (!await mondayTriageStillPending(candidate)) return 'triage_no_longer_pending';
271
270
  } else if (cadence === 'daily') {
272
271
  const inactiveSince = moment.tz(now, DEFAULT_TIMEZONE).subtract(INACTIVITY_DAYS, 'days').toDate();
273
- const silentSince = moment.tz(now, DEFAULT_TIMEZONE).subtract(MAX_SILENCE_DAYS, 'days').toDate();
274
- const [lastInbound] = await getMessages(
275
- { numero: candidate.code, from_me: false, createdAt: { $gte: silentSince } },
276
- { select: { createdAt: 1 }, sort: { createdAt: -1 }, limit: 1 },
272
+ const recentInbound = await getMessages(
273
+ { numero: candidate.code, from_me: false, createdAt: { $gte: inactiveSince } },
274
+ { sort: { createdAt: -1 }, limit: 1 },
277
275
  );
278
- if (!lastInbound) return 'patient_silent_beyond_max_window';
279
- if (new Date(lastInbound.createdAt) >= inactiveSince) return 'patient_active_within_inactivity_window';
276
+ if (recentInbound?.length) return 'patient_active_within_inactivity_window';
280
277
 
281
278
  const lastTriageAt = candidate?.brief?.lastTriageAt;
282
279
  if (lastTriageAt && moment.tz(lastTriageAt, DEFAULT_TIMEZONE).isSameOrAfter(inactiveSince)) {
@@ -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 written to the patient record — they are held back 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
- 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) {
@@ -5,6 +5,7 @@ const { reference, patientReference, codeableConcept, extension, toInstant } = r
5
5
  const { baseResource, problemListCategory, observationCategory } = require('../helpers/resourceHelper');
6
6
  const { MENTION_SLUG } = require('../constants/projectionSlugs');
7
7
  const { MENTION_EXT } = require('../constants/extensionSlugs');
8
+ const { PROCEDURE_STATUS, statusFrom } = require('./contextFactProjection');
8
9
 
9
10
  const ACTIVITY_TEXT = 'clinical-mention-extraction';
10
11
  const OBSERVED_FIELD_SYSTEM_KEY = 'observedField';
@@ -137,7 +138,8 @@ function adherenceFor(assertion) {
137
138
 
138
139
  function procedureStatus(fact, provenance) {
139
140
  if (fact.assertion.negated) return 'not-done';
140
- return provenance.needsConfirmation ? 'unknown' : 'completed';
141
+ if (provenance.needsConfirmation) return 'unknown';
142
+ return statusFrom(PROCEDURE_STATUS, fact.observedFields?.status, { absent: 'completed', unrecognised: 'completed' });
141
143
  }
142
144
 
143
145
  function procedureResource({ fact, provenance }) {
@@ -33,7 +33,7 @@ const PROCEDURE_STATUS = {
33
33
  ended: 'completed', realizada: 'completed', realizado: 'completed',
34
34
  stopped: 'stopped', suspendido: 'stopped', suspendida: 'stopped',
35
35
  suspended: 'stopped', discontinued: 'stopped', not_active: 'stopped',
36
- paused: 'on-hold',
36
+ paused: 'on-hold', started: 'in-progress',
37
37
  finalizado: 'completed', end: 'completed', past: 'completed', previous: 'completed',
38
38
  historical: 'completed', historico: 'completed',
39
39
  cancelled: 'not-done', cancelado: 'not-done', cancelada: 'not-done',
@@ -363,6 +363,8 @@ function fromFhir(resources) {
363
363
 
364
364
  module.exports = {
365
365
  CONTEXT_RESOURCE_TYPES,
366
+ PROCEDURE_STATUS,
367
+ statusFrom,
366
368
  toFhir,
367
369
  fromFhir,
368
370
  conceptKeyOf,
@@ -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
  }
@@ -98,6 +98,7 @@ const draftSchema = new mongoose.Schema({
98
98
  missingCharacterization: { type: [String], default: undefined },
99
99
  suggestCall: { type: suggestCallSchema, default: undefined },
100
100
  sectionsUnavailable: { type: [String], default: undefined },
101
+ snapshotAt: { type: Date, default: undefined },
101
102
  }, { timestamps: { createdAt: true, updatedAt: false } });
102
103
 
103
104
  const doctorHandoffSchema = new mongoose.Schema({
@@ -44,7 +44,7 @@ const AUDIT_SOURCE = 'escalation_review';
44
44
  const RECENT_REVIEW_LIMIT = 20;
45
45
  const TIMELINE_LIMIT = 500;
46
46
  const MAX_REVIEW_UPDATES = 50;
47
- const DRAFT_DEBOUNCE_MS = 60 * 1000;
47
+ const DRAFT_DEBOUNCE_MS = 20 * 1000;
48
48
  const DRAFT_OPERATOR = 'bot:escalationDraft';
49
49
  const DIRECT_CHAT_ATTEMPTS = 3;
50
50
  const RECHECK_DELAY_MS = 60 * 1000;
@@ -507,6 +507,25 @@ async function recordReviewUpdate({ code, update, now = new Date(), settleInBack
507
507
  }
508
508
  }
509
509
 
510
+ const STALE_INTERRUPTION_NOTES = 'Cerrada automáticamente al llegar una alarma nueva; nadie verificó si la operación interrumpida se completó';
511
+
512
+ async function closeStaleInterruption(EscalationReview, patientCode, now) {
513
+ try {
514
+ const stale = await EscalationReview.findOne({
515
+ patientCode,
516
+ closedAt: null,
517
+ status: { $in: INTERRUPTED_STATUSES },
518
+ updatedAt: { $lte: new Date(now.getTime() - INTERRUPTION_GRACE_MS) },
519
+ }, { updatedAt: 1 }).lean();
520
+ if (!stale) return;
521
+ await closeEscalationReview({
522
+ id: String(stale._id), closedBy: BOT_OPERATOR, interruptionNotes: STALE_INTERRUPTION_NOTES, expectedUpdatedAt: stale.updatedAt,
523
+ });
524
+ } catch (error) {
525
+ logger[error?.statusCode === 409 ? 'warn' : 'error']('[EscalationReview] The interrupted review was not closed before the new alarm', { error: redactedError(error) });
526
+ }
527
+ }
528
+
510
529
  async function recordEscalationAlarm({ code, alarm, now = new Date() } = {}) {
511
530
  try {
512
531
  const patientCode = patientCodeOf(code);
@@ -518,6 +537,7 @@ async function recordEscalationAlarm({ code, alarm, now = new Date() } = {}) {
518
537
  .lean();
519
538
 
520
539
  await ensureActiveReviewIndex(EscalationReview);
540
+ await closeStaleInterruption(EscalationReview, patientCode, now);
521
541
  const joined = await join();
522
542
  if (joined) {
523
543
  await scheduleDoctorAlert(joined, entry, now);
@@ -1078,6 +1098,7 @@ async function settleFollowUp(snapshot, { background = true } = {}) {
1078
1098
  }
1079
1099
 
1080
1100
  async function generateBotDraft({ id, lease = null } = {}) {
1101
+ const startedAt = new Date();
1081
1102
  const review = await loadReview(reviewIdOf(id));
1082
1103
  if (review.closedAt || !DOCUMENTING_STATUSES.includes(review.status)) return { review: null, retry: false };
1083
1104
  const latest = (review.drafts ?? []).at(-1);
@@ -1107,6 +1128,7 @@ async function generateBotDraft({ id, lease = null } = {}) {
1107
1128
  }
1108
1129
 
1109
1130
  const entry = {
1131
+ snapshotAt: startedAt,
1110
1132
  doctorText: withIdentity({ identity, review, doctorText: draft.doctorText }),
1111
1133
  patientMessage: draft.patientMessage,
1112
1134
  writtenBy: DRAFT_OPERATOR,
@@ -1117,13 +1139,16 @@ async function generateBotDraft({ id, lease = null } = {}) {
1117
1139
  ...(draft.suggestCall && { suggestCall: draft.suggestCall }),
1118
1140
  ...(draft.sectionsUnavailable?.length && { sectionsUnavailable: draft.sectionsUnavailable }),
1119
1141
  };
1120
- const saved = await getEscalationReview().findOneAndUpdate(
1121
- { _id: review._id, status: { $in: DOCUMENTING_STATUSES }, draftDueAt: lease, drafts: { $size: (review.drafts ?? []).length } },
1122
- { $push: { drafts: entry }, $set: { status: 'draft_ready', draftDueAt: null, draftFailedAt: null }, $unset: { identityBlock: '' } },
1142
+ const EscalationReview = getEscalationReview();
1143
+ const saved = await EscalationReview.findOneAndUpdate(
1144
+ { _id: review._id, status: { $in: DOCUMENTING_STATUSES }, draftDueAt: { $lte: lease }, drafts: { $size: (review.drafts ?? []).length } },
1145
+ { $push: { drafts: entry }, $set: { status: 'draft_ready', draftFailedAt: null }, $unset: { identityBlock: '' } },
1123
1146
  { new: true },
1124
1147
  ).lean();
1125
- if (saved) await audit(saved, DRAFT_OPERATOR, null, { action: 'draft_saved', draftNumber: saved.drafts.length });
1126
- return { review: saved, retry: false };
1148
+ if (!saved) return { review: null, retry: false };
1149
+ const released = await EscalationReview.updateOne({ _id: review._id, draftDueAt: lease }, { $set: { draftDueAt: null } }, BOOKKEEPING);
1150
+ await audit(saved, DRAFT_OPERATOR, null, { action: 'draft_saved', draftNumber: saved.drafts.length });
1151
+ return { review: released.matchedCount ? { ...saved, draftDueAt: null } : saved, retry: false };
1127
1152
  }
1128
1153
 
1129
1154
  async function noteFactsContext(review) {
@@ -1341,7 +1366,7 @@ async function handOffToDoctor({ id, mode, physicianEmail = null, sentBy, reques
1341
1366
  if (!approver) throw httpError(403, 'The physician is not an escalation review approver');
1342
1367
  }
1343
1368
 
1344
- const draftedAt = draft.createdAt ? new Date(draft.createdAt).getTime() : null;
1369
+ const draftedAt = draftTimeOf(draft) ? new Date(draftTimeOf(draft)).getTime() : null;
1345
1370
  if (draftedAt && (review.alarms ?? []).some((alarm) => new Date(alarm.raisedAt).getTime() > draftedAt)) {
1346
1371
  throw httpError(409, 'A new alarm arrived after this draft was written; review it and save a new draft before sending it to the doctor');
1347
1372
  }
@@ -1437,7 +1462,7 @@ async function recordDoctorDecision({
1437
1462
  // Truthiness, not != null: an empty textarea is a decline with nothing to say.
1438
1463
  if (decision === 'needs_intervention' && finalText) approvedText = requiredText(finalText, 'finalText');
1439
1464
 
1440
- if (APPROVALS.includes(decision) && arrivedAfter(review, draft.createdAt)) {
1465
+ if (APPROVALS.includes(decision) && arrivedAfter(review, draftTimeOf(draft))) {
1441
1466
  if (review.status === 'awaiting_doctor') {
1442
1467
  const redraft = draft.writtenBy === DRAFT_OPERATOR ? { draftDueAt: new Date(), draftAttempts: 0 } : {};
1443
1468
  const returned = await updateReview(reviewId, ['awaiting_doctor'], { $set: { status: 'documenting', ...redraft } }, {
@@ -1552,6 +1577,8 @@ const latestUpdateGuard = (review) => {
1552
1577
  return { updates: { $size: updates.length }, [`updates.${updates.length - 1}.turnId`]: updates.at(-1).turnId };
1553
1578
  };
1554
1579
 
1580
+ const draftTimeOf = (draft) => draft.snapshotAt ?? draft.createdAt;
1581
+
1555
1582
  const arrivedAfter = (review, since) => Boolean(since) && [
1556
1583
  ...(review.alarms ?? []).map((alarm) => alarm.raisedAt),
1557
1584
  ...(review.updates ?? []).map((update) => update.at),
@@ -6,7 +6,7 @@ const { getEscalationReview } = require('../models/escalationReviewModel');
6
6
  const { BOOKKEEPING, DOCUMENTING_STATUSES, generateBotDraft } = require('./escalationReviewService');
7
7
 
8
8
  const SWEEP_LIMIT = 1;
9
- const DEFAULT_SWEEP_INTERVAL_MS = 30 * 1000;
9
+ const DEFAULT_SWEEP_INTERVAL_MS = 10 * 1000;
10
10
  const RETRY_DELAY_MS = 5 * 60 * 1000;
11
11
  const MAX_DRAFT_ATTEMPTS = 3;
12
12
  const LEASE_MS = 10 * 60 * 1000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.69.0-dev.12295",
3
+ "version": "5.69.0-dev.12577",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",