@peopl-health/nexus 5.11.0-dev.1132 → 5.11.0-dev.1133
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/AssistantProcessor.js +3 -7
- package/lib/clinical/context/ToolRuntimeContext.js +4 -10
- package/lib/clinical/providers/AnthropicProvider.js +5 -9
- package/lib/clinical/providers/BaseLLMProvider.js +4 -9
- package/lib/clinical/providers/OpenAIResponsesProvider.js +7 -7
- package/lib/clinical/providers/OpenRouterProvider.js +1 -7
- package/lib/clinical/services/bridgeService.js +7 -31
- package/lib/clinical/services/clinicalAirtableService.js +11 -19
- package/lib/clinical/services/shadowService.js +1 -1
- package/lib/clinical/services/skillService.js +47 -10
- package/lib/clinical/tools/extractClinicalInfoTool.js +0 -1
- package/lib/clinical/tools/schedulePatientReminderTool.js +4 -12
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@ const { overlayCohortPreset } = require('./helpers/cohortHelper');
|
|
|
8
8
|
const { forkShadowTurn } = require('./services/shadowService');
|
|
9
9
|
const { diverge } = require('./services/divergenceService');
|
|
10
10
|
const { getAssistantById } = require('./services/assistantResolver');
|
|
11
|
-
const { maybeSendBridge
|
|
11
|
+
const { maybeSendBridge } = require('./services/bridgeService');
|
|
12
12
|
const { getBridgePresetId } = require('./flags/bridgeConfig');
|
|
13
13
|
const { ToolRuntimeContext } = require('./context/ToolRuntimeContext');
|
|
14
14
|
|
|
@@ -45,11 +45,7 @@ class AssistantProcessor {
|
|
|
45
45
|
|
|
46
46
|
if (thread.isInCohort && !runOptions.toolRuntimeContext && runOptions.runId && thread.code) {
|
|
47
47
|
try {
|
|
48
|
-
runOptions.toolRuntimeContext = new ToolRuntimeContext({
|
|
49
|
-
patientCode: thread.code,
|
|
50
|
-
turnId: runOptions.runId,
|
|
51
|
-
shouldContinue: runOptions.shouldContinue,
|
|
52
|
-
});
|
|
48
|
+
runOptions.toolRuntimeContext = new ToolRuntimeContext({ patientCode: thread.code, turnId: runOptions.runId });
|
|
53
49
|
} catch (error) {
|
|
54
50
|
logger.warn('[AssistantProcessor] turn will run untraced', { code: thread.code, error: error.message });
|
|
55
51
|
}
|
|
@@ -87,6 +83,7 @@ class AssistantProcessor {
|
|
|
87
83
|
outbound: output,
|
|
88
84
|
completed: runResult?.completed,
|
|
89
85
|
predictionTimeMs,
|
|
86
|
+
shouldContinue: runOptions.shouldContinue,
|
|
90
87
|
});
|
|
91
88
|
} catch (error) {
|
|
92
89
|
logger.warn('[AssistantProcessor] trace not stored', {
|
|
@@ -139,7 +136,6 @@ class AssistantProcessor {
|
|
|
139
136
|
if (!this.sendMessage) throw new Error('sendMessage function not configured');
|
|
140
137
|
if (!result?.output) return null;
|
|
141
138
|
await this.sendMessage({ code, body: result.output, processed: true, origin: 'assistant', tools_executed: result.tools_executed, prompt: result.prompt, preset: result.preset, response_id: result.response_id, turnId: result.turnId, traceId: result.traceId });
|
|
142
|
-
clearBridgeState(code);
|
|
143
139
|
return result.output;
|
|
144
140
|
}
|
|
145
141
|
}
|
|
@@ -8,7 +8,6 @@ class ToolRuntimeContext {
|
|
|
8
8
|
repos = {},
|
|
9
9
|
contextSource = null,
|
|
10
10
|
shadow = false,
|
|
11
|
-
shouldContinue = null,
|
|
12
11
|
} = {}) {
|
|
13
12
|
if (typeof patientCode !== 'string' || !patientCode) {
|
|
14
13
|
throw new Error('ToolRuntimeContext requires a non-empty patientCode');
|
|
@@ -23,7 +22,6 @@ class ToolRuntimeContext {
|
|
|
23
22
|
this.patientCode = patientCode;
|
|
24
23
|
this.turnId = turnId;
|
|
25
24
|
this.sessionId = sessionId;
|
|
26
|
-
this.shouldContinue = shouldContinue;
|
|
27
25
|
this.repos = repos;
|
|
28
26
|
this.contextSource = contextSource;
|
|
29
27
|
this.enabledSkills = [];
|
|
@@ -44,17 +42,13 @@ class ToolRuntimeContext {
|
|
|
44
42
|
return toolData;
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
|
|
48
|
-
return typeof this.shouldContinue !== 'function' || this.shouldContinue();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async store({ outbound = null, completed = false, predictionTimeMs = null } = {}) {
|
|
45
|
+
async store({ outbound = null, completed = false, predictionTimeMs = null, shouldContinue = null } = {}) {
|
|
52
46
|
this.trace.setSignals({ activatedSkills: [...this.activatedSkills].sort() });
|
|
53
47
|
this.trace.setOutbound(outbound);
|
|
54
|
-
|
|
55
|
-
this.trace.setStatus(superseded ? 'superseded' : (completed ? 'completed' : 'incomplete'));
|
|
48
|
+
this.trace.setStatus(completed ? 'completed' : 'incomplete');
|
|
56
49
|
this.trace.setTimings({ predictionTimeMs });
|
|
57
|
-
|
|
50
|
+
const superseded = shouldContinue && !shouldContinue();
|
|
51
|
+
if (!superseded) await this.trace.save();
|
|
58
52
|
}
|
|
59
53
|
|
|
60
54
|
setSafetyArtifact(artifact) {
|
|
@@ -11,7 +11,7 @@ const { logger } = require('../../utils/logger');
|
|
|
11
11
|
|
|
12
12
|
const runtimeConfig = require('../../config/runtimeConfig');
|
|
13
13
|
|
|
14
|
-
const { BaseLLMProvider, CONVERSATION_CONTINUATION, DELIVERY_NUDGE, MAX_DELIVERY_NUDGES, TERMINAL_DELIVERY_TOOLS
|
|
14
|
+
const { BaseLLMProvider, CONVERSATION_CONTINUATION, DELIVERY_NUDGE, MAX_DELIVERY_NUDGES, TERMINAL_DELIVERY_TOOLS } = require('./BaseLLMProvider');
|
|
15
15
|
const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
|
|
16
16
|
|
|
17
17
|
const DEFAULT_MAX_FUNCTION_ROUNDS = 12;
|
|
@@ -69,7 +69,7 @@ class AnthropicProvider extends BaseLLMProvider {
|
|
|
69
69
|
async _invokeModel({
|
|
70
70
|
instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
|
|
71
71
|
modelConfig, assistant, phiProcessor = null, trace = null, onFirstIteration = null,
|
|
72
|
-
resolvedPromptId = null, resolvedPresetId = null
|
|
72
|
+
resolvedPromptId = null, resolvedPresetId = null
|
|
73
73
|
}, options = {}) {
|
|
74
74
|
const executeTools = options.toolExecutor || handleFunctionCalls;
|
|
75
75
|
let totalRetries = 0;
|
|
@@ -170,7 +170,7 @@ class AnthropicProvider extends BaseLLMProvider {
|
|
|
170
170
|
&& (response.content || []).some(block => block.type === 'tool_use');
|
|
171
171
|
try {
|
|
172
172
|
const decision = onFirstIteration({ narrationText, hasToolCalls });
|
|
173
|
-
if (decision?.dedupNote) bridgeNote = decision.dedupNote;
|
|
173
|
+
if (decision?.sent && decision.dedupNote) bridgeNote = decision.dedupNote;
|
|
174
174
|
} catch (err) {
|
|
175
175
|
logger.warn('[AnthropicProvider] bridge callback failed', { error: err.message });
|
|
176
176
|
}
|
|
@@ -187,10 +187,6 @@ class AnthropicProvider extends BaseLLMProvider {
|
|
|
187
187
|
let deliveryBreak = false;
|
|
188
188
|
const maxIterations = this.maxFunctionRounds + MAX_DELIVERY_NUDGES + LOOP_ITERATION_HEADROOM;
|
|
189
189
|
for (let iteration = 1; iteration <= maxIterations; iteration++) {
|
|
190
|
-
if (isSuperseded(shouldContinue)) {
|
|
191
|
-
logger.info('[AnthropicProvider] turn superseded — aborting tool loop', { iteration });
|
|
192
|
-
break;
|
|
193
|
-
}
|
|
194
190
|
const content = finalResponse.content || [];
|
|
195
191
|
const toolUses = content.filter(block => block.type === 'tool_use');
|
|
196
192
|
const ended = finalResponse.stop_reason !== 'tool_use' || !toolUses.length;
|
|
@@ -255,9 +251,9 @@ class AnthropicProvider extends BaseLLMProvider {
|
|
|
255
251
|
}
|
|
256
252
|
|
|
257
253
|
// Budget exhausted mid-work: the loop stopped while the model still wanted tools, so the reply may be empty. Make it
|
|
258
|
-
// visible — but not on a
|
|
254
|
+
// visible — but not on a delivery break, which also exits with pending calls and is not exhaustion.
|
|
259
255
|
const pendingTools = (finalResponse.content || []).filter(block => block.type === 'tool_use');
|
|
260
|
-
if (finalResponse.stop_reason === 'tool_use' && pendingTools.length && !deliveryBreak
|
|
256
|
+
if (finalResponse.stop_reason === 'tool_use' && pendingTools.length && !deliveryBreak) {
|
|
261
257
|
logger.warn('[AnthropicProvider] Tool-iteration budget exhausted with pending tool calls', { maxFunctionRounds: this.maxFunctionRounds, pending: pendingTools.map(b => b.name) });
|
|
262
258
|
trace?.setSignals?.({ toolBudgetExhausted: true });
|
|
263
259
|
}
|
|
@@ -18,10 +18,6 @@ const TERMINAL_DELIVERY_TOOLS = new Set(['DeliverPatientMessage']);
|
|
|
18
18
|
const DELIVERY_NUDGE = 'sistema: el turno no puede cerrar sin un mensaje para el paciente. Llama ahora a DeliverPatientMessage con message_text en español. No repitas herramientas clínicas ya ejecutadas en este turno.';
|
|
19
19
|
const MAX_DELIVERY_NUDGES = 1;
|
|
20
20
|
|
|
21
|
-
function isSuperseded(shouldContinue) {
|
|
22
|
-
return typeof shouldContinue === 'function' && !shouldContinue();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
21
|
class BaseLLMProvider {
|
|
26
22
|
constructor(options = {}) {
|
|
27
23
|
if (new.target === BaseLLMProvider) {
|
|
@@ -336,8 +332,8 @@ class BaseLLMProvider {
|
|
|
336
332
|
return result;
|
|
337
333
|
}
|
|
338
334
|
|
|
339
|
-
if (
|
|
340
|
-
logger.info('[runConversation]
|
|
335
|
+
if (config.shadow && !config.shouldContinue()) {
|
|
336
|
+
logger.info('[runConversation] Shadow superseded — returning without retry', { attempt });
|
|
341
337
|
return result;
|
|
342
338
|
}
|
|
343
339
|
|
|
@@ -348,8 +344,8 @@ class BaseLLMProvider {
|
|
|
348
344
|
await new Promise(r => setTimeout(r, 500));
|
|
349
345
|
} catch (error) {
|
|
350
346
|
logger.error('[runConversation] Attempt failed', { attempt, error: error.message });
|
|
351
|
-
if (
|
|
352
|
-
logger.info('[runConversation]
|
|
347
|
+
if (config.shadow && !config.shouldContinue()) {
|
|
348
|
+
logger.info('[runConversation] Shadow superseded — abandoning after error', { attempt });
|
|
353
349
|
return { status: 'cancelled', output_text: '', tools_executed: [], retries: 0 };
|
|
354
350
|
}
|
|
355
351
|
if (attempt === maxRetries) throw error;
|
|
@@ -532,5 +528,4 @@ module.exports = {
|
|
|
532
528
|
DELIVERY_NUDGE,
|
|
533
529
|
MAX_DELIVERY_NUDGES,
|
|
534
530
|
TERMINAL_DELIVERY_TOOLS,
|
|
535
|
-
isSuperseded,
|
|
536
531
|
};
|
|
@@ -9,7 +9,7 @@ const { logger } = require('../../utils/logger');
|
|
|
9
9
|
|
|
10
10
|
const runtimeConfig = require('../../config/runtimeConfig');
|
|
11
11
|
|
|
12
|
-
const { BaseLLMProvider
|
|
12
|
+
const { BaseLLMProvider } = require('./BaseLLMProvider');
|
|
13
13
|
const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
|
|
14
14
|
|
|
15
15
|
const DEFAULT_MAX_FUNCTION_ROUNDS = 12;
|
|
@@ -69,7 +69,7 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
|
|
|
69
69
|
shadow = false, shouldContinue = null, onFirstIteration = null
|
|
70
70
|
}, options = {}) {
|
|
71
71
|
const executeTools = options.toolExecutor || handleFunctionCalls;
|
|
72
|
-
const
|
|
72
|
+
const shadowSuperseded = () => shadow && !shouldContinue();
|
|
73
73
|
let totalRetries = 0;
|
|
74
74
|
const allToolsExecuted = [];
|
|
75
75
|
const allToolCalls = [];
|
|
@@ -127,7 +127,7 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
|
|
|
127
127
|
const hasToolCalls = Boolean(assistant) && (response.output || []).some(item => item.type === 'function_call');
|
|
128
128
|
try {
|
|
129
129
|
const decision = onFirstIteration({ narrationText, hasToolCalls });
|
|
130
|
-
if (decision?.dedupNote) bridgeNote = decision.dedupNote;
|
|
130
|
+
if (decision?.sent && decision.dedupNote) bridgeNote = decision.dedupNote;
|
|
131
131
|
} catch (err) {
|
|
132
132
|
logger.warn('[OpenAIResponsesProvider] bridge callback failed', { error: err.message });
|
|
133
133
|
}
|
|
@@ -140,8 +140,8 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
|
|
|
140
140
|
apiCallConfig.tool_choice = followUpToolChoice;
|
|
141
141
|
|
|
142
142
|
for (let round = 1; round <= this.maxFunctionRounds; round++) {
|
|
143
|
-
if (
|
|
144
|
-
logger.info('[OpenAIResponsesProvider]
|
|
143
|
+
if (shadowSuperseded()) {
|
|
144
|
+
logger.info('[OpenAIResponsesProvider] shadow superseded — aborting tool loop', { round });
|
|
145
145
|
break;
|
|
146
146
|
}
|
|
147
147
|
const functionCalls = finalResponse.output.filter(item => item.type === 'function_call');
|
|
@@ -167,9 +167,9 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
// Budget exhausted mid-work: the loop stopped while the model still wanted tools, so the reply may be empty. Make it
|
|
170
|
-
// visible — but not on a supersede or delivery break, which also exit with pending calls and are not exhaustion.
|
|
170
|
+
// visible — but not on a shadow-supersede or delivery break, which also exit with pending calls and are not exhaustion.
|
|
171
171
|
const pendingCalls = (finalResponse.output || []).filter(item => item.type === 'function_call');
|
|
172
|
-
if (pendingCalls.length && !
|
|
172
|
+
if (pendingCalls.length && !shadowSuperseded() && !deliveryBreak) {
|
|
173
173
|
logger.warn('[OpenAIResponsesProvider] Tool-iteration budget exhausted with pending tool calls', { maxFunctionRounds: this.maxFunctionRounds, pending: pendingCalls.map(c => c.name) });
|
|
174
174
|
trace?.setSignals?.({ toolBudgetExhausted: true });
|
|
175
175
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
const { OpenAI } = require('openai');
|
|
2
2
|
|
|
3
|
-
const { logger } = require('../../utils/logger');
|
|
4
3
|
const { retryWithBackoff } = require('../../utils/retryUtils');
|
|
5
4
|
const { withTiming } = require('../../utils/tracingDecorator');
|
|
6
5
|
const { safeParse } = require('../../utils/jsonUtils');
|
|
7
6
|
|
|
8
7
|
const { OpenAIResponsesProvider } = require('./OpenAIResponsesProvider');
|
|
9
|
-
const { isSuperseded } = require('./BaseLLMProvider');
|
|
10
8
|
const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
|
|
11
9
|
|
|
12
10
|
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
@@ -39,7 +37,7 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
|
|
|
39
37
|
|
|
40
38
|
async _invokeModel({
|
|
41
39
|
instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
|
|
42
|
-
modelConfig, assistant, phiProcessor = null, trace = null
|
|
40
|
+
modelConfig, assistant, phiProcessor = null, trace = null
|
|
43
41
|
}, options = {}) {
|
|
44
42
|
const executeTools = options.toolExecutor || handleFunctionCalls;
|
|
45
43
|
let totalRetries = 0;
|
|
@@ -76,10 +74,6 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
|
|
|
76
74
|
apiCallConfig.tool_choice = followUpToolChoice;
|
|
77
75
|
|
|
78
76
|
for (let round = 1; round <= this.maxFunctionRounds; round++) {
|
|
79
|
-
if (isSuperseded(shouldContinue)) {
|
|
80
|
-
logger.info('[OpenRouterProvider] turn superseded — aborting tool loop', { round });
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
77
|
const message = finalResponse.choices?.[0]?.message;
|
|
84
78
|
const toolCalls = message?.tool_calls || [];
|
|
85
79
|
if (!toolCalls.length) break;
|
|
@@ -25,9 +25,9 @@ function buildDedupNote(bridgeText) {
|
|
|
25
25
|
function maybeSendBridge({ narrationText, hasToolCalls, code, turnId = null, traceId = null, shouldContinue, sendMessage }) {
|
|
26
26
|
if (!isBridgeEnabled()) return { sent: false };
|
|
27
27
|
|
|
28
|
-
const skip = (reason
|
|
28
|
+
const skip = (reason) => {
|
|
29
29
|
logger.info('[bridge] skipped', { code, reason });
|
|
30
|
-
return { sent: false
|
|
30
|
+
return { sent: false };
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
if (typeof shouldContinue === 'function' && !shouldContinue()) return skip('superseded');
|
|
@@ -36,44 +36,20 @@ function maybeSendBridge({ narrationText, hasToolCalls, code, turnId = null, tra
|
|
|
36
36
|
const text = sanitizeBridgeText(narrationText);
|
|
37
37
|
if (!text) return skip('no_usable_narration');
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
if (pending !== undefined) {
|
|
41
|
-
return skip('recent_bridge_in_thread', pending.delivered ? { dedupNote: pending.dedupNote } : {});
|
|
42
|
-
}
|
|
39
|
+
if (bridgeTimestamps.get(code) !== undefined) return skip('recent_bridge_in_thread');
|
|
43
40
|
|
|
44
|
-
|
|
45
|
-
const entry = { dedupNote, delivered: false, clearWhenDelivered: false };
|
|
46
|
-
bridgeTimestamps.set(code, entry);
|
|
47
|
-
const failed = (err) => {
|
|
48
|
-
bridgeTimestamps.delete(code);
|
|
49
|
-
logger.warn('[bridge] send failed', { code, error: err.message });
|
|
50
|
-
};
|
|
51
|
-
const delivered = () => {
|
|
52
|
-
entry.delivered = true;
|
|
53
|
-
if (entry.clearWhenDelivered && bridgeTimestamps.get(code) === entry) bridgeTimestamps.delete(code);
|
|
54
|
-
};
|
|
41
|
+
bridgeTimestamps.set(code, true);
|
|
55
42
|
try {
|
|
56
43
|
Promise.resolve(sendMessage({ code, body: text, processed: true, origin: 'assistant', raw: { bridge: true }, turnId, traceId }))
|
|
57
|
-
.
|
|
58
|
-
.catch(failed);
|
|
44
|
+
.catch(err => logger.warn('[bridge] send failed', { code, error: err.message }));
|
|
59
45
|
} catch (err) {
|
|
60
|
-
failed
|
|
46
|
+
logger.warn('[bridge] send failed', { code, error: err.message });
|
|
61
47
|
}
|
|
62
48
|
|
|
63
49
|
logger.info('[bridge] sent', { code });
|
|
64
|
-
return { sent: true, text, dedupNote };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function clearBridgeState(code) {
|
|
68
|
-
const pending = bridgeTimestamps.get(code);
|
|
69
|
-
if (pending && !pending.delivered) {
|
|
70
|
-
pending.clearWhenDelivered = true;
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
bridgeTimestamps.delete(code);
|
|
50
|
+
return { sent: true, text, dedupNote: buildDedupNote(text) };
|
|
74
51
|
}
|
|
75
52
|
|
|
76
53
|
module.exports = {
|
|
77
54
|
maybeSendBridge,
|
|
78
|
-
clearBridgeState,
|
|
79
55
|
};
|
|
@@ -21,21 +21,16 @@ const INTAKE_SOURCE_TO_AIRTABLE = {
|
|
|
21
21
|
team_relay: 'doctor',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
async function auditedWrite({ trace, code, table, write
|
|
24
|
+
async function auditedWrite({ trace, code, table, write }) {
|
|
25
25
|
const entry = { table, at: new Date().toISOString(), ok: false };
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (record?.id) entry.recordId = record.id;
|
|
35
|
-
} catch (err) {
|
|
36
|
-
entry.error = err?.message || String(err);
|
|
37
|
-
logger.error('[clinicalAirtableService] Airtable write failed', { table, code, error: entry.error });
|
|
38
|
-
}
|
|
26
|
+
try {
|
|
27
|
+
const result = await write();
|
|
28
|
+
entry.ok = true;
|
|
29
|
+
const record = Array.isArray(result) ? result[0] : result;
|
|
30
|
+
if (record?.id) entry.recordId = record.id;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
entry.error = err?.message || String(err);
|
|
33
|
+
logger.error('[clinicalAirtableService] Airtable write failed', { table, code, error: entry.error });
|
|
39
34
|
}
|
|
40
35
|
if (trace?.setSignals) {
|
|
41
36
|
const existing = Array.isArray(trace.signals?.airtableWrites) ? trace.signals.airtableWrites : [];
|
|
@@ -97,11 +92,10 @@ async function recordEmergency({ code, trace, symptom, suspectedCondition = '',
|
|
|
97
92
|
});
|
|
98
93
|
}
|
|
99
94
|
|
|
100
|
-
async function recordClinicalIntake({ code, trace, messageRaw, source = 'self'
|
|
95
|
+
async function recordClinicalIntake({ code, trace, messageRaw, source = 'self' }) {
|
|
101
96
|
return auditedWrite({
|
|
102
97
|
trace,
|
|
103
98
|
code,
|
|
104
|
-
isLive,
|
|
105
99
|
table: 'intake',
|
|
106
100
|
write: async () => {
|
|
107
101
|
const patientId = await findIntakePatientRecordId(code);
|
|
@@ -116,13 +110,12 @@ async function recordClinicalIntake({ code, trace, messageRaw, source = 'self',
|
|
|
116
110
|
});
|
|
117
111
|
}
|
|
118
112
|
|
|
119
|
-
async function recordReminder({ code, trace, actionType, reminderId, reminderDateUtc, reminderType, description
|
|
113
|
+
async function recordReminder({ code, trace, actionType, reminderId, reminderDateUtc, reminderType, description }) {
|
|
120
114
|
const type = REMINDER_TYPE_TO_AIRTABLE[reminderType] || reminderType;
|
|
121
115
|
if (actionType === 'update') {
|
|
122
116
|
return auditedWrite({
|
|
123
117
|
trace,
|
|
124
118
|
code,
|
|
125
|
-
isLive,
|
|
126
119
|
table: 'reminder',
|
|
127
120
|
write: async () => {
|
|
128
121
|
const updated = await airtableService.updateRecordByFilter(
|
|
@@ -140,7 +133,6 @@ async function recordReminder({ code, trace, actionType, reminderId, reminderDat
|
|
|
140
133
|
return auditedWrite({
|
|
141
134
|
trace,
|
|
142
135
|
code,
|
|
143
|
-
isLive,
|
|
144
136
|
table: 'reminder',
|
|
145
137
|
write: () => airtableService.addLinkedRecord(
|
|
146
138
|
Monitoreo_ID,
|
|
@@ -27,7 +27,7 @@ async function runShadowTurn({
|
|
|
27
27
|
const shadowAssistant = resolveAssistant(shadowThread);
|
|
28
28
|
|
|
29
29
|
const context = new ToolRuntimeContext({
|
|
30
|
-
patientCode: code, turnId: runId, sessionId, shadow: true,
|
|
30
|
+
patientCode: code, turnId: runId, sessionId, shadow: true,
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
const runOptions = {
|
|
@@ -11,26 +11,63 @@ const TOOLS_TABLE = 'tools';
|
|
|
11
11
|
const CACHE_TTL = 5 * 60 * 1000;
|
|
12
12
|
const CACHE_KEY = 'skills';
|
|
13
13
|
const TOOLS_CACHE_KEY = 'toolCatalog';
|
|
14
|
+
const BACKTICK_SPAN_RX = /`([^`\n]+)`/g;
|
|
15
|
+
const LEADING_CALL_RX = /^([A-Za-z][A-Za-z0-9]*)\s*(?:[(.]|$)/;
|
|
16
|
+
const MULTI_WORD_RX = /[a-z][A-Z]/;
|
|
14
17
|
|
|
15
18
|
const cache = new MapCache({ maxSize: 2, ttl: CACHE_TTL });
|
|
16
19
|
|
|
17
|
-
async function
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
async function readToolCatalog() {
|
|
21
|
+
const cached = cache.get(TOOLS_CACHE_KEY);
|
|
22
|
+
if (cached) return cached;
|
|
23
|
+
|
|
24
|
+
let records;
|
|
25
|
+
try {
|
|
26
|
+
records = await getRecordByFilter(Config_ID, TOOLS_TABLE, 'TRUE()');
|
|
27
|
+
} catch (error) {
|
|
28
|
+
logger.warn('[skillService] tool catalog unreadable; drift check falls back to the skill body', { error: error.message });
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (!records) {
|
|
32
|
+
logger.warn('[skillService] tool catalog unreadable; drift check falls back to the skill body', { table: TOOLS_TABLE });
|
|
33
|
+
return null;
|
|
23
34
|
}
|
|
35
|
+
|
|
36
|
+
const catalog = records.map((row) => row.tool_id).filter((id) => typeof id === 'string' && id.trim() !== '');
|
|
37
|
+
cache.set(TOOLS_CACHE_KEY, catalog);
|
|
38
|
+
return catalog;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function unavailableToolNames() {
|
|
42
|
+
const catalog = await readToolCatalog();
|
|
43
|
+
if (!catalog) return [];
|
|
24
44
|
const registered = new Set(getRegisteredToolNames());
|
|
25
45
|
return catalog.filter((id) => !registered.has(id));
|
|
26
46
|
}
|
|
27
47
|
|
|
48
|
+
function escapeRegExp(value) {
|
|
49
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mentionedToolNames(text) {
|
|
53
|
+
const names = new Set();
|
|
54
|
+
for (const [, span] of text.matchAll(BACKTICK_SPAN_RX)) {
|
|
55
|
+
const id = span.match(LEADING_CALL_RX)?.[1];
|
|
56
|
+
if (id && MULTI_WORD_RX.test(id)) names.add(id);
|
|
57
|
+
}
|
|
58
|
+
return names;
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
async function warnOnRegistryDrift(name, body) {
|
|
29
|
-
|
|
62
|
+
const registeredNames = getRegisteredToolNames();
|
|
63
|
+
if (registeredNames.length === 0) return;
|
|
64
|
+
const registered = new Set(registeredNames);
|
|
30
65
|
const text = String(body || '');
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
66
|
+
|
|
67
|
+
const fromCatalog = (await unavailableToolNames()).filter((id) => new RegExp(`\\b${escapeRegExp(id)}\\b`).test(text));
|
|
68
|
+
const fromBody = [...mentionedToolNames(text)].filter((id) => !registered.has(id));
|
|
69
|
+
const missing = [...new Set([...fromCatalog, ...fromBody])].sort();
|
|
70
|
+
|
|
34
71
|
if (missing.length > 0) {
|
|
35
72
|
logger.error('[skillService] Skill body calls tools that are not registered — the model cannot follow this body', {
|
|
36
73
|
skill: name, missing,
|
|
@@ -107,18 +107,11 @@ async function handler(args = {}, context = {}) {
|
|
|
107
107
|
};
|
|
108
108
|
trace.setSignals({ reminderIntents: [...existing, intent] });
|
|
109
109
|
|
|
110
|
-
const isLive = runtime.isLive?.bind(runtime);
|
|
111
|
-
const superseded = Boolean(isLive) && !isLive();
|
|
112
|
-
|
|
113
110
|
if (runtime.patientCode) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
await dispatchReminder({ intent, patientId: runtime.patientCode });
|
|
119
|
-
} catch (err) {
|
|
120
|
-
logger.warn('[schedulePatientReminder] reminder FHIR projection failed; ack unaffected', { turnId, error: err?.message });
|
|
121
|
-
}
|
|
111
|
+
try {
|
|
112
|
+
await dispatchReminder({ intent, patientId: runtime.patientCode });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logger.warn('[schedulePatientReminder] reminder FHIR projection failed; ack unaffected', { turnId, error: err?.message });
|
|
122
115
|
}
|
|
123
116
|
}
|
|
124
117
|
|
|
@@ -131,7 +124,6 @@ async function handler(args = {}, context = {}) {
|
|
|
131
124
|
reminderDateUtc: fireMoment.toISOString(),
|
|
132
125
|
reminderType,
|
|
133
126
|
description: storedDescription,
|
|
134
|
-
isLive,
|
|
135
127
|
});
|
|
136
128
|
}
|
|
137
129
|
|